@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.
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;
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createAkanValidationContract } from "./akanMcpContract";
3
+
4
+ describe("createAkanValidationContract", () => {
5
+ test("exposes reference-first tooling rollout gate semantics", () => {
6
+ const contract = createAkanValidationContract();
7
+
8
+ expect(contract.toolingRolloutGate).toMatchObject({
9
+ schemaVersion: 1,
10
+ strategy: "reference-first-dependency-later",
11
+ });
12
+ expect(contract.toolingRolloutGate.candidates).toContainEqual(
13
+ expect.objectContaining({
14
+ packageName: "typescript",
15
+ status: "allowed",
16
+ }),
17
+ );
18
+ expect(contract.toolingRolloutGate.candidates).toContainEqual(
19
+ expect.objectContaining({
20
+ packageName: "@ttsc/graph",
21
+ status: "reference-only",
22
+ }),
23
+ );
24
+ });
25
+
26
+ test("keeps dependency adoption gated by package and source-body constraints", () => {
27
+ const contract = createAkanValidationContract();
28
+
29
+ expect(contract.toolingRolloutGate.gateConditions).toContain(
30
+ "Works in Bun runtime and published package artifacts.",
31
+ );
32
+ expect(contract.toolingRolloutGate.gateConditions).toContain("Does not break dist/pkgs package verification.");
33
+ expect(contract.toolingRolloutGate.gateConditions).toContain(
34
+ "Does not move MCP responses toward returning source bodies.",
35
+ );
36
+ });
37
+ });