@akanjs/devkit 2.3.9-rc.2 → 2.3.9-rc.4

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/akanContext.ts CHANGED
@@ -4,7 +4,15 @@ import { capitalize } from "akanjs/common";
4
4
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
5
5
  import { FileSys } from "./fileSys";
6
6
  import type { PackageJson } from "./types";
7
- import type { RepairAction } from "./workflow";
7
+ import {
8
+ type GeneratedSyncState,
9
+ type RepairAction,
10
+ type WorkflowApplyReport,
11
+ type WorkflowPlan,
12
+ type WorkflowRunArtifact,
13
+ workflowRunArtifactPath,
14
+ workflowSyncDir,
15
+ } from "./workflow";
8
16
 
9
17
  export type AkanContextFormat = "json" | "markdown";
10
18
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -61,6 +69,29 @@ export interface AkanDiagnostic {
61
69
  message: string;
62
70
  path?: string;
63
71
  repairActions?: RepairAction[];
72
+ scope?: "baseline" | "workflow" | "unknown";
73
+ context?: {
74
+ workflow?: string;
75
+ planPath?: string;
76
+ runId?: string;
77
+ target?: string;
78
+ paths?: string[];
79
+ };
80
+ }
81
+
82
+ export interface GeneratedFilesFreshness {
83
+ status: "fresh" | "stale" | "missing" | "unknown";
84
+ message: string;
85
+ refreshCommand: string;
86
+ verifyingCommands: string[];
87
+ targets?: {
88
+ target: string;
89
+ status: "fresh" | "stale" | "missing" | "unknown";
90
+ lastSyncedAt?: string;
91
+ runId?: string;
92
+ generatedFiles: string[];
93
+ reason: string;
94
+ }[];
64
95
  }
65
96
 
66
97
  export interface AkanDoctorResult {
@@ -71,14 +102,11 @@ export interface AkanDoctorResult {
71
102
  status: "passed" | "failed";
72
103
  diagnostics: AkanDiagnostic[];
73
104
  generatedFiles: string[];
74
- generatedFilesFreshness: {
75
- status: "unknown";
76
- message: string;
77
- refreshCommand: string;
78
- verifyingCommands: string[];
79
- };
105
+ generatedFilesFreshness: GeneratedFilesFreshness;
80
106
  validationCommands: string[];
81
107
  repairActions: RepairAction[];
108
+ baselineDiagnostics?: AkanDiagnostic[];
109
+ workflowDiagnostics?: AkanDiagnostic[];
82
110
  }
83
111
 
84
112
  export type JsonRpcRequest = {
@@ -136,6 +164,7 @@ export const renderDoctorText = (result: AkanDoctorResult) => {
136
164
  lines.push(
137
165
  "",
138
166
  "Generated file freshness:",
167
+ `Status: ${result.generatedFilesFreshness.status}`,
139
168
  result.generatedFilesFreshness.message,
140
169
  `Refresh: ${result.generatedFilesFreshness.refreshCommand}`,
141
170
  "",
@@ -182,7 +211,7 @@ const validationCommands = [
182
211
  "akan doctor --strict --format json",
183
212
  ];
184
213
 
185
- const generatedFilesFreshness = {
214
+ const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
186
215
  status: "unknown" as const,
187
216
  message: "Run sync before validation so generated Akan files match the current source conventions.",
188
217
  refreshCommand: "akan sync <app-or-lib>",
@@ -269,6 +298,135 @@ const safeReadJson = async <T>(filePath: string) => {
269
298
  }
270
299
  };
271
300
 
301
+ const isWorkflowPlan = (value: unknown): value is WorkflowPlan =>
302
+ typeof value === "object" &&
303
+ value !== null &&
304
+ "schemaVersion" in value &&
305
+ value.schemaVersion === 1 &&
306
+ "mode" in value &&
307
+ value.mode === "plan";
308
+
309
+ const isWorkflowApplyReport = (value: unknown): value is WorkflowApplyReport =>
310
+ typeof value === "object" &&
311
+ value !== null &&
312
+ "schemaVersion" in value &&
313
+ value.schemaVersion === 1 &&
314
+ "mode" in value &&
315
+ (value.mode === "apply" || value.mode === "dry-run");
316
+
317
+ const isWorkflowRunArtifact = (value: unknown): value is WorkflowRunArtifact =>
318
+ typeof value === "object" && value !== null && "schemaVersion" in value && value.schemaVersion === 1;
319
+
320
+ const planInputString = (plan: WorkflowPlan, key: string) => {
321
+ const value = plan.inputs[key];
322
+ return typeof value === "string" ? value : "";
323
+ };
324
+
325
+ const expandWorkflowTarget = (target: string, plan: WorkflowPlan) => {
326
+ const app = planInputString(plan, "app");
327
+ const module = planInputString(plan, "module");
328
+ const moduleClass = module ? capitalize(module) : "<Module>";
329
+ return target
330
+ .replace(/^\*\//, app ? `apps/${app}/` : "")
331
+ .replaceAll("<module>", module || "<module>")
332
+ .replaceAll("<Module>", moduleClass);
333
+ };
334
+
335
+ const workflowPathsForPlan = (plan: WorkflowPlan) =>
336
+ plan.predictedChanges.map((change) => expandWorkflowTarget(change.target, plan));
337
+
338
+ const workflowPathsForArtifact = (artifact: WorkflowRunArtifact) => {
339
+ if (isWorkflowPlan(artifact)) return workflowPathsForPlan(artifact);
340
+ if (isWorkflowApplyReport(artifact)) {
341
+ return [
342
+ ...artifact.changedFiles.map((file) => file.path),
343
+ ...artifact.generatedFiles.map((file) => file.path),
344
+ ...workflowPathsForPlan(artifact.plan),
345
+ ];
346
+ }
347
+ if ("mode" in artifact && artifact.mode === "validate" && artifact.plan) return workflowPathsForPlan(artifact.plan);
348
+ return [];
349
+ };
350
+
351
+ const loadWorkflowContextPaths = async (
352
+ workspace: WorkspaceExecutor,
353
+ runIdOrPlan: string | null,
354
+ changedFiles: string[],
355
+ ) => {
356
+ const paths = [...changedFiles];
357
+ if (!runIdOrPlan) return paths;
358
+ const inputPath = path.isAbsolute(runIdOrPlan) ? runIdOrPlan : path.join(workspace.workspaceRoot, runIdOrPlan);
359
+ const artifact =
360
+ (await safeReadJson<WorkflowRunArtifact | WorkflowPlan>(inputPath)) ??
361
+ (await safeReadJson<WorkflowRunArtifact>(path.join(workspace.workspaceRoot, workflowRunArtifactPath(runIdOrPlan))));
362
+ if (artifact && isWorkflowRunArtifact(artifact)) paths.push(...workflowPathsForArtifact(artifact));
363
+ return [...new Set(paths.filter(Boolean))];
364
+ };
365
+
366
+ const pathKey = (value: string) =>
367
+ value
368
+ .replaceAll("\\", "/")
369
+ .replaceAll("*", "")
370
+ .replace(/<[^>]+>/g, "")
371
+ .replace(/\/+/g, "/")
372
+ .replace(/^\/|\/$/g, "");
373
+
374
+ const isWorkflowRelatedDiagnostic = (diagnostic: AkanDiagnostic, workflowPaths: string[]) => {
375
+ if (!diagnostic.path) return false;
376
+ const diagnosticPath = pathKey(diagnostic.path);
377
+ return workflowPaths.some((workflowPath) => {
378
+ const candidate = pathKey(workflowPath);
379
+ if (!candidate) return false;
380
+ return (
381
+ diagnosticPath.startsWith(candidate) || candidate.startsWith(diagnosticPath) || diagnosticPath.includes(candidate)
382
+ );
383
+ });
384
+ };
385
+
386
+ const readGeneratedSyncStates = async (workspace: WorkspaceExecutor) => {
387
+ const syncDir = path.join(workspace.workspaceRoot, workflowSyncDir);
388
+ const entries = await safeReadDir(syncDir);
389
+ const states = await Promise.all(
390
+ entries
391
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
392
+ .map((entry) => safeReadJson<GeneratedSyncState>(path.join(syncDir, entry.name))),
393
+ );
394
+ return states.filter(
395
+ (state): state is GeneratedSyncState =>
396
+ !!state && state.schemaVersion === 1 && typeof state.target === "string" && typeof state.syncedAt === "string",
397
+ );
398
+ };
399
+
400
+ const generatedFreshnessFromStates = async (workspace: WorkspaceExecutor): Promise<GeneratedFilesFreshness> => {
401
+ const states = await readGeneratedSyncStates(workspace);
402
+ if (states.length === 0) return unknownGeneratedFilesFreshness;
403
+ const targets = states
404
+ .sort((a, b) => a.target.localeCompare(b.target))
405
+ .map((state) => ({
406
+ target: state.target,
407
+ status: state.status === "passed" ? ("fresh" as const) : ("stale" as const),
408
+ lastSyncedAt: state.syncedAt,
409
+ runId: state.runId,
410
+ generatedFiles: state.generatedFiles.map((file) => file.path),
411
+ reason:
412
+ state.status === "passed"
413
+ ? `Generated files were refreshed by ${state.command}.`
414
+ : `Last generated repair command failed: ${state.command}.`,
415
+ }));
416
+ const lastFresh = targets
417
+ .filter((target) => target.status === "fresh" && target.lastSyncedAt)
418
+ .sort((a, b) => (b.lastSyncedAt ?? "").localeCompare(a.lastSyncedAt ?? ""))[0];
419
+ return {
420
+ status: targets.some((target) => target.status === "fresh") ? "fresh" : "stale",
421
+ message: lastFresh
422
+ ? `Generated files were refreshed for ${lastFresh.target} at ${lastFresh.lastSyncedAt}.`
423
+ : "Generated sync state exists, but the last recorded repair did not pass.",
424
+ refreshCommand: "akan sync <app-or-lib>",
425
+ verifyingCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>"],
426
+ targets,
427
+ };
428
+ };
429
+
272
430
  const parseAbstractSummary = (
273
431
  relativePath: string,
274
432
  content: string | null,
@@ -422,9 +580,14 @@ export class AkanContextAnalyzer {
422
580
 
423
581
  static async doctor(
424
582
  workspace: WorkspaceExecutor,
425
- { strict = false }: { strict?: boolean } = {},
583
+ {
584
+ strict = false,
585
+ runIdOrPlan = null,
586
+ changedFiles = [],
587
+ }: { strict?: boolean; runIdOrPlan?: string | null; changedFiles?: string[] } = {},
426
588
  ): Promise<AkanDoctorResult> {
427
589
  const context = await AkanContextAnalyzer.analyze(workspace);
590
+ const workflowPaths = await loadWorkflowContextPaths(workspace, runIdOrPlan, changedFiles);
428
591
  const diagnostics: AkanDiagnostic[] = [];
429
592
  const repairActions: RepairAction[] = [
430
593
  repairAction("generated", "akan repair generated --app <app-or-lib>", "Refresh generated Akan files.", true),
@@ -524,22 +687,40 @@ export class AkanContextAnalyzer {
524
687
  }
525
688
  }
526
689
 
690
+ const scopedDiagnostics = diagnostics.map((diagnostic) => ({
691
+ ...diagnostic,
692
+ scope: workflowPaths.length
693
+ ? isWorkflowRelatedDiagnostic(diagnostic, workflowPaths)
694
+ ? ("workflow" as const)
695
+ : ("baseline" as const)
696
+ : diagnostic.scope,
697
+ context: workflowPaths.length ? { ...diagnostic.context, paths: workflowPaths } : diagnostic.context,
698
+ }));
699
+ const workflowDiagnostics = scopedDiagnostics.filter((diagnostic) => diagnostic.scope === "workflow");
700
+ const baselineDiagnostics = scopedDiagnostics.filter((diagnostic) => diagnostic.scope === "baseline");
527
701
  return {
528
702
  schemaVersion: 1,
529
703
  repoName: context.repoName,
530
704
  root: context.root,
531
705
  strict,
532
- status: diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "failed" : "passed",
533
- diagnostics,
706
+ status: scopedDiagnostics.some((diagnostic) => diagnostic.severity === "error") ? "failed" : "passed",
707
+ diagnostics: scopedDiagnostics,
534
708
  generatedFiles: context.generatedFiles,
535
- generatedFilesFreshness,
709
+ generatedFilesFreshness: await generatedFreshnessFromStates(workspace),
536
710
  validationCommands: context.validationCommands,
537
711
  repairActions,
712
+ ...(workflowPaths.length ? { baselineDiagnostics, workflowDiagnostics } : {}),
538
713
  };
539
714
  }
540
715
 
541
- static findModules(context: AkanWorkspaceContext, moduleName?: string | null) {
542
- const modules = [...context.apps, ...context.libs].flatMap((sys) => sys.modules);
716
+ static findModules(
717
+ context: AkanWorkspaceContext,
718
+ moduleName?: string | null,
719
+ { app = null }: { app?: string | null } = {},
720
+ ) {
721
+ const modules = [...context.apps, ...context.libs]
722
+ .filter((sys) => !app || sys.name === app)
723
+ .flatMap((sys) => sys.modules);
543
724
  return moduleName
544
725
  ? modules.filter((module) => module.name === moduleName || module.folderName === moduleName)
545
726
  : modules;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.9-rc.2",
3
+ "version": "2.3.9-rc.4",
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.2",
35
+ "akanjs": "2.3.9-rc.4",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
@@ -0,0 +1,396 @@
1
+ import type { Workspace } from "../commandDecorators";
2
+ import type {
3
+ GeneratedSyncState,
4
+ PrimitiveChangedFile,
5
+ PrimitiveGeneratedFile,
6
+ RepairAction,
7
+ RepairReport,
8
+ WorkflowApplyCommand,
9
+ WorkflowApplyReport,
10
+ WorkflowDiagnostic,
11
+ WorkflowFailureScope,
12
+ WorkflowKnownBlocker,
13
+ WorkflowPlan,
14
+ WorkflowRunArtifact,
15
+ WorkflowRunSource,
16
+ WorkflowValidationCommandResult,
17
+ WorkflowValidationRunReport,
18
+ WorkflowValidationStatus,
19
+ } from "./types";
20
+ import { commandStatus, jsonText, uniqueBy, workflowStatus } from "./utils";
21
+
22
+ export const createWorkflowApplyReport = ({
23
+ workflow,
24
+ mode,
25
+ changedFiles = [],
26
+ generatedFiles = [],
27
+ appliedCommands = [],
28
+ recommendedValidationCommands,
29
+ commands = [],
30
+ diagnostics = [],
31
+ recommendations = [],
32
+ nextActions = [],
33
+ plan,
34
+ }: Omit<
35
+ WorkflowApplyReport,
36
+ | "schemaVersion"
37
+ | "runId"
38
+ | "applyReportPath"
39
+ | "validationTarget"
40
+ | "status"
41
+ | "appliedCommands"
42
+ | "recommendedValidationCommands"
43
+ | "commands"
44
+ | "recommendations"
45
+ > & {
46
+ appliedCommands?: WorkflowApplyCommand[];
47
+ recommendedValidationCommands?: WorkflowApplyCommand[];
48
+ commands?: WorkflowApplyCommand[];
49
+ recommendations?: WorkflowApplyReport["recommendations"];
50
+ }): WorkflowApplyReport => {
51
+ const validationCommands = recommendedValidationCommands ?? commands;
52
+ return {
53
+ schemaVersion: 1,
54
+ workflow,
55
+ mode,
56
+ status: workflowStatus(diagnostics),
57
+ changedFiles: uniqueBy(changedFiles, (file) => `${file.action}:${file.path}:${file.reason}`),
58
+ generatedFiles: uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`),
59
+ appliedCommands: uniqueBy(appliedCommands, (command) => command.command),
60
+ recommendedValidationCommands: uniqueBy(validationCommands, (command) => command.command),
61
+ commands: uniqueBy(validationCommands, (command) => command.command),
62
+ diagnostics,
63
+ recommendations: uniqueBy(recommendations, (recommendation) => `${recommendation.kind}:${recommendation.code}`),
64
+ nextActions: uniqueBy(nextActions, (action) => action.command),
65
+ plan,
66
+ };
67
+ };
68
+
69
+ export const resolveWorkflowCommand = (command: string, plan: WorkflowPlan) => {
70
+ const target = typeof plan.inputs.app === "string" ? plan.inputs.app : "<app-or-lib>";
71
+ return command
72
+ .replaceAll("<app-or-lib-or-pkg>", target)
73
+ .replaceAll("<app-or-lib>", target)
74
+ .replaceAll("<app-name>", target);
75
+ };
76
+
77
+ export const workflowCommandsForPlan = (plan: WorkflowPlan) =>
78
+ plan.validation.map((validation) => ({
79
+ command: resolveWorkflowCommand(validation.command, plan),
80
+ reason: validation.reason,
81
+ kind: validation.kind,
82
+ })) satisfies WorkflowApplyCommand[];
83
+
84
+ export const workflowRunsDir = ".akan/workflows/runs";
85
+
86
+ export const createWorkflowRunId = (prefix = "run") =>
87
+ `${prefix}-${new Date()
88
+ .toISOString()
89
+ .replace(/[-:.TZ]/g, "")
90
+ .slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
91
+
92
+ const getRunId = (artifact: WorkflowRunArtifact) => {
93
+ if ("runId" in artifact && artifact.runId) return artifact.runId;
94
+ return createWorkflowRunId("mode" in artifact ? artifact.mode : artifact.kind);
95
+ };
96
+
97
+ export const workflowRunArtifactPath = (runId: string) => `${workflowRunsDir}/${runId}.json`;
98
+
99
+ const withWorkflowRunMetadata = (
100
+ artifact: WorkflowRunArtifact,
101
+ runId: string,
102
+ artifactPath: string,
103
+ ): WorkflowRunArtifact => {
104
+ if ("mode" in artifact && (artifact.mode === "apply" || artifact.mode === "dry-run")) {
105
+ return { ...artifact, runId, applyReportPath: artifactPath, validationTarget: artifactPath };
106
+ }
107
+ if ("kind" in artifact) return { ...artifact, runId, repairReportPath: artifactPath };
108
+ return artifact;
109
+ };
110
+
111
+ export const writeWorkflowRunArtifact = async (workspace: Workspace, artifact: WorkflowRunArtifact) => {
112
+ const runId = getRunId(artifact);
113
+ const artifactPath = workflowRunArtifactPath(runId);
114
+ const artifactWithMetadata = withWorkflowRunMetadata(artifact, runId, artifactPath);
115
+ await workspace.writeFile(artifactPath, jsonText(artifactWithMetadata), { silent: true });
116
+ return { runId, path: artifactPath, artifact: artifactWithMetadata };
117
+ };
118
+
119
+ export const workflowSyncDir = ".akan/workflows/sync";
120
+
121
+ const syncStateSlug = (target: string) =>
122
+ target
123
+ .trim()
124
+ .replace(/[^a-zA-Z0-9]+/g, "-")
125
+ .replace(/^-+|-+$/g, "")
126
+ .toLowerCase();
127
+
128
+ export const workflowSyncStatePath = (target: string) => `${workflowSyncDir}/${syncStateSlug(target) || "target"}.json`;
129
+
130
+ export const generatedFilePathsForTarget = (targetRoot: string, reason = "Generated files were refreshed by sync.") =>
131
+ [
132
+ { path: `${targetRoot}/lib/cnst.ts`, action: "sync", reason },
133
+ { path: `${targetRoot}/lib/dict.ts`, action: "sync", reason },
134
+ { path: `${targetRoot}/lib/option.ts`, action: "sync", reason },
135
+ { path: `${targetRoot}/lib/index.ts`, action: "sync", reason },
136
+ ] satisfies PrimitiveGeneratedFile[];
137
+
138
+ export const writeGeneratedSyncState = async (workspace: Workspace, state: GeneratedSyncState) => {
139
+ const statePath = workflowSyncStatePath(state.target);
140
+ await workspace.writeFile(statePath, jsonText(state), { silent: true });
141
+ return statePath;
142
+ };
143
+
144
+ export const readWorkflowRunArtifact = async (workspace: Workspace, runId: string) => {
145
+ const artifactPath = workflowRunArtifactPath(runId);
146
+ if (!(await workspace.exists(artifactPath))) throw new Error(`Workflow run artifact does not exist: ${artifactPath}`);
147
+ return (await workspace.readJson(artifactPath)) as WorkflowRunArtifact;
148
+ };
149
+
150
+ export type WorkflowValidationCommandExecutor = (
151
+ command: WorkflowApplyCommand,
152
+ ) => Promise<WorkflowValidationCommandResult>;
153
+
154
+ const failedCommandScopes = (commands: readonly WorkflowValidationCommandResult[]) =>
155
+ commands.filter((command) => command.status === "failed").map((command) => command.failureScope ?? "unknown");
156
+
157
+ const errorDiagnosticScopes = (diagnostics: readonly WorkflowDiagnostic[]) =>
158
+ diagnostics
159
+ .filter((diagnostic) => diagnostic.severity === "error")
160
+ .map(
161
+ (diagnostic) =>
162
+ diagnostic.failureScope ??
163
+ (diagnostic.scope === "baseline"
164
+ ? "workspace-config"
165
+ : diagnostic.scope === "workflow"
166
+ ? "source-change"
167
+ : "unknown"),
168
+ );
169
+
170
+ const hasScopeFailure = (
171
+ scopes: readonly WorkflowFailureScope[],
172
+ scope: WorkflowFailureScope,
173
+ diagnostics: readonly WorkflowDiagnostic[] = [],
174
+ ) =>
175
+ scopes.includes(scope) ||
176
+ diagnostics.some((diagnostic) => diagnostic.severity === "error" && diagnostic.failureScope === scope);
177
+
178
+ const statusForScope = (
179
+ commands: readonly WorkflowValidationCommandResult[],
180
+ diagnostics: readonly WorkflowDiagnostic[],
181
+ scopes: readonly WorkflowFailureScope[],
182
+ expectedScope: WorkflowFailureScope,
183
+ ): WorkflowValidationStatus => {
184
+ const hasCommands = commands.length > 0 || diagnostics.length > 0;
185
+ if (!hasCommands) return "unknown";
186
+ return hasScopeFailure(scopes, expectedScope, diagnostics) ? "failed" : "passed";
187
+ };
188
+
189
+ const createKnownBlockers = (
190
+ commands: readonly WorkflowValidationCommandResult[],
191
+ diagnostics: readonly WorkflowDiagnostic[],
192
+ ): WorkflowKnownBlocker[] => {
193
+ const commandBlockers = commands
194
+ .filter(
195
+ (command) =>
196
+ command.status === "failed" &&
197
+ (command.failureScope === "workspace-config" || command.failureScope === "environment"),
198
+ )
199
+ .map((command) => ({
200
+ code: `workflow-validation-${command.failureScope}`,
201
+ message: `${command.failureScope === "environment" ? "Environment" : "Workspace configuration"} blocker: ${command.command}`,
202
+ failureScope: command.failureScope ?? "unknown",
203
+ command: command.command,
204
+ kind: command.kind,
205
+ count: 1,
206
+ }));
207
+ const diagnosticBlockers = diagnostics
208
+ .filter(
209
+ (diagnostic) =>
210
+ diagnostic.severity === "error" &&
211
+ (diagnostic.failureScope === "workspace-config" ||
212
+ diagnostic.failureScope === "environment" ||
213
+ diagnostic.scope === "baseline"),
214
+ )
215
+ .map((diagnostic) => ({
216
+ code: diagnostic.code,
217
+ message: diagnostic.message,
218
+ failureScope: diagnostic.failureScope ?? ("workspace-config" as const),
219
+ command: diagnostic.command,
220
+ kind: diagnostic.kind,
221
+ count: 1,
222
+ }));
223
+ const grouped = new Map<string, WorkflowKnownBlocker>();
224
+ for (const blocker of [...commandBlockers, ...diagnosticBlockers]) {
225
+ const key = `${blocker.failureScope}:${blocker.code}:${blocker.command ?? ""}:${blocker.message}`;
226
+ const existing = grouped.get(key);
227
+ if (existing) existing.count += blocker.count;
228
+ else grouped.set(key, blocker);
229
+ }
230
+ return [...grouped.values()];
231
+ };
232
+
233
+ const createValidationStatuses = (
234
+ commands: readonly WorkflowValidationCommandResult[],
235
+ diagnostics: readonly WorkflowDiagnostic[],
236
+ ) => {
237
+ const scopes = [...failedCommandScopes(commands), ...errorDiagnosticScopes(diagnostics)];
238
+ const sourceStatus = statusForScope(commands, diagnostics, scopes, "source-change");
239
+ const workspaceStatus =
240
+ hasScopeFailure(scopes, "workspace-config", diagnostics) || hasScopeFailure(scopes, "environment", diagnostics)
241
+ ? "failed"
242
+ : commands.length || diagnostics.length
243
+ ? "passed"
244
+ : "unknown";
245
+ const overallStatus = hasScopeFailure(scopes, "source-change", diagnostics)
246
+ ? "failed"
247
+ : hasScopeFailure(scopes, "workspace-config", diagnostics)
248
+ ? "blocked-by-workspace-config"
249
+ : hasScopeFailure(scopes, "environment", diagnostics)
250
+ ? "blocked-by-environment"
251
+ : workflowStatus(diagnostics) === "failed" || commandStatus(commands) === "failed"
252
+ ? "failed"
253
+ : "passed";
254
+ return { sourceStatus, workspaceStatus, overallStatus };
255
+ };
256
+
257
+ export const createWorkflowValidationRunReport = async ({
258
+ runId = createWorkflowRunId("validation"),
259
+ workflow,
260
+ source,
261
+ plan,
262
+ commands,
263
+ execute,
264
+ diagnostics = [],
265
+ baselineDiagnostics = [],
266
+ workflowDiagnostics = [],
267
+ repairActions = [],
268
+ }: {
269
+ runId?: string;
270
+ workflow: string;
271
+ source: WorkflowRunSource;
272
+ plan?: WorkflowPlan;
273
+ commands: WorkflowApplyCommand[];
274
+ execute: WorkflowValidationCommandExecutor;
275
+ diagnostics?: WorkflowDiagnostic[];
276
+ baselineDiagnostics?: WorkflowDiagnostic[];
277
+ workflowDiagnostics?: WorkflowDiagnostic[];
278
+ repairActions?: RepairAction[];
279
+ }): Promise<WorkflowValidationRunReport> => {
280
+ const results: WorkflowValidationCommandResult[] = [];
281
+ for (const command of commands) {
282
+ results.push(await execute(command));
283
+ }
284
+ const commandDiagnostics = results.flatMap((result) =>
285
+ result.status === "failed"
286
+ ? [
287
+ {
288
+ severity: "error" as const,
289
+ code: "workflow-validation-command-failed",
290
+ message: `Validation command failed: ${result.command}`,
291
+ command: result.command,
292
+ kind: result.kind,
293
+ failureScope: result.failureScope,
294
+ },
295
+ ]
296
+ : [],
297
+ );
298
+ const reportDiagnostics = [...diagnostics, ...commandDiagnostics];
299
+ const scopedDiagnostics = [...reportDiagnostics, ...baselineDiagnostics, ...workflowDiagnostics];
300
+ const statuses = createValidationStatuses(results, scopedDiagnostics);
301
+ return {
302
+ schemaVersion: 1,
303
+ runId,
304
+ workflow,
305
+ mode: "validate",
306
+ source,
307
+ status: statuses.overallStatus === "passed" ? "passed" : "failed",
308
+ ...statuses,
309
+ knownBlockers: createKnownBlockers(results, scopedDiagnostics),
310
+ commands: results,
311
+ diagnostics: reportDiagnostics,
312
+ baselineDiagnostics,
313
+ workflowDiagnostics,
314
+ repairActions: uniqueBy(repairActions, (action) => action.command),
315
+ nextActions: results
316
+ .filter((result) => result.status === "failed")
317
+ .map((result) => ({ command: result.command, reason: "Re-run this validation command after repair." })),
318
+ plan,
319
+ };
320
+ };
321
+
322
+ export const createDryRunWorkflowApplyReport = (plan: WorkflowPlan) => {
323
+ const changedFiles: PrimitiveChangedFile[] = plan.predictedChanges.flatMap((change) => {
324
+ if (change.action !== "create" && change.action !== "modify") return [];
325
+ return [
326
+ {
327
+ path: change.target,
328
+ action: change.action,
329
+ reason: change.reason,
330
+ },
331
+ ];
332
+ });
333
+ const generatedFiles: PrimitiveGeneratedFile[] = plan.predictedChanges.flatMap((change) => {
334
+ if (change.action !== "sync") return [];
335
+ return [
336
+ {
337
+ path: change.target,
338
+ action: "sync",
339
+ reason: change.reason,
340
+ },
341
+ ];
342
+ });
343
+ const diagnostics = [...plan.diagnostics];
344
+ return createWorkflowApplyReport({
345
+ workflow: plan.workflow,
346
+ mode: "dry-run",
347
+ changedFiles,
348
+ generatedFiles,
349
+ commands: workflowCommandsForPlan(plan),
350
+ diagnostics,
351
+ recommendations: plan.recommendations,
352
+ nextActions: workflowCommandsForPlan(plan),
353
+ plan,
354
+ });
355
+ };
356
+
357
+ export const createRepairReport = ({
358
+ command,
359
+ kind,
360
+ target = null,
361
+ diagnostics = [],
362
+ repairActions = [],
363
+ nextActions = [],
364
+ commands = [],
365
+ generatedFiles = [],
366
+ syncedAt,
367
+ }: Omit<
368
+ RepairReport,
369
+ | "schemaVersion"
370
+ | "runId"
371
+ | "repairReportPath"
372
+ | "status"
373
+ | "target"
374
+ | "diagnostics"
375
+ | "repairActions"
376
+ | "nextActions"
377
+ | "commands"
378
+ > &
379
+ Partial<
380
+ Pick<
381
+ RepairReport,
382
+ "target" | "diagnostics" | "repairActions" | "nextActions" | "commands" | "generatedFiles" | "syncedAt"
383
+ >
384
+ >): RepairReport => ({
385
+ schemaVersion: 1,
386
+ command,
387
+ kind,
388
+ target,
389
+ status: workflowStatus(diagnostics) === "failed" || commandStatus(commands) === "failed" ? "failed" : "passed",
390
+ diagnostics,
391
+ repairActions: uniqueBy(repairActions, (action) => action.command),
392
+ nextActions: uniqueBy(nextActions, (action) => action.command),
393
+ commands,
394
+ generatedFiles,
395
+ ...(syncedAt ? { syncedAt } : {}),
396
+ });