@akanjs/devkit 2.3.9-rc.0 → 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/aiEditor.test.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import {
3
- parseTypescriptFileBlocks,
4
- preserveTypescriptResponseContent,
5
- } from "./aiEditor";
2
+ import { parseTypescriptFileBlocks, preserveTypescriptResponseContent } from "./aiEditor";
6
3
 
7
4
  describe("parseTypescriptFileBlocks", () => {
8
5
  test("parses TypeScript file blocks with common fence variants", () => {
@@ -39,12 +36,9 @@ export const CarUnit = () => null;
39
36
  export const car = "car";
40
37
  \`\`\`
41
38
  `;
42
- const nextContent =
43
- "The generated file meets all specified requirements. No rewrite is necessary.";
39
+ const nextContent = "The generated file meets all specified requirements. No rewrite is necessary.";
44
40
 
45
- expect(
46
- preserveTypescriptResponseContent(previousContent, nextContent),
47
- ).toBe(previousContent);
41
+ expect(preserveTypescriptResponseContent(previousContent, nextContent)).toBe(previousContent);
48
42
  });
49
43
 
50
44
  test("uses next code response when validation rewrites with parseable files", () => {
@@ -61,8 +55,6 @@ export const car = "updated";
61
55
  \`\`\`
62
56
  `;
63
57
 
64
- expect(
65
- preserveTypescriptResponseContent(previousContent, nextContent),
66
- ).toBe(nextContent);
58
+ expect(preserveTypescriptResponseContent(previousContent, nextContent)).toBe(nextContent);
67
59
  });
68
60
  });
package/akanContext.ts CHANGED
@@ -4,6 +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 {
8
+ type GeneratedSyncState,
9
+ type RepairAction,
10
+ type WorkflowApplyReport,
11
+ type WorkflowPlan,
12
+ type WorkflowRunArtifact,
13
+ workflowRunArtifactPath,
14
+ workflowSyncDir,
15
+ } from "./workflow";
7
16
 
8
17
  export type AkanContextFormat = "json" | "markdown";
9
18
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -59,14 +68,115 @@ export interface AkanDiagnostic {
59
68
  code: string;
60
69
  message: string;
61
70
  path?: string;
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
+ }[];
62
95
  }
63
96
 
64
97
  export interface AkanDoctorResult {
65
98
  schemaVersion: 1;
99
+ repoName: string;
100
+ root: string;
66
101
  strict: boolean;
102
+ status: "passed" | "failed";
67
103
  diagnostics: AkanDiagnostic[];
104
+ generatedFiles: string[];
105
+ generatedFilesFreshness: GeneratedFilesFreshness;
106
+ validationCommands: string[];
107
+ repairActions: RepairAction[];
108
+ baselineDiagnostics?: AkanDiagnostic[];
109
+ workflowDiagnostics?: AkanDiagnostic[];
68
110
  }
69
111
 
112
+ export type JsonRpcRequest = {
113
+ jsonrpc?: "2.0";
114
+ id?: string | number | null;
115
+ method: string;
116
+ params?: Record<string, unknown>;
117
+ };
118
+
119
+ export type McpFraming = "content-length" | "newline";
120
+ export type AkanMcpMode = "readonly" | "plan" | "apply";
121
+
122
+ export type CursorMcpConfig = {
123
+ mcpServers?: Record<string, unknown>;
124
+ };
125
+
126
+ export const resourceList = [
127
+ { uri: "akan://docs/framework", name: "Akan framework guide", mimeType: "text/markdown" },
128
+ { uri: "akan://guidelines/framework", name: "Framework guideline", mimeType: "text/markdown" },
129
+ { uri: "akan://guidelines/modelSignal", name: "Model signal guideline", mimeType: "text/markdown" },
130
+ { uri: "akan://workspace/summary", name: "Workspace summary", mimeType: "application/json" },
131
+ { uri: "akan://workspace/apps", name: "Workspace apps", mimeType: "application/json" },
132
+ { uri: "akan://workspace/modules", name: "Workspace modules", mimeType: "application/json" },
133
+ ];
134
+
135
+ export const cursorMcpConfigPath = ".cursor/mcp.json";
136
+
137
+ const cursorWorkspaceFolder = "$" + "{workspaceFolder}";
138
+
139
+ export const createAkanCursorMcpServer = (mode: AkanMcpMode = "readonly") => ({
140
+ type: "stdio",
141
+ command: "bash",
142
+ args: ["-lc", `cd "${cursorWorkspaceFolder}" && akan mcp --mode ${mode}`],
143
+ });
144
+
145
+ export const akanCursorMcpServer = createAkanCursorMcpServer();
146
+
147
+ export const renderDoctorText = (result: AkanDoctorResult) => {
148
+ const lines = [`Akan doctor status: ${result.status}`];
149
+ if (result.diagnostics.length === 0) {
150
+ lines.push("", "No Akan workspace diagnostics found.");
151
+ } else {
152
+ lines.push(
153
+ "",
154
+ ...result.diagnostics.map((diagnostic) =>
155
+ [
156
+ `[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`,
157
+ diagnostic.path ? ` ${diagnostic.path}` : "",
158
+ ]
159
+ .filter(Boolean)
160
+ .join("\n"),
161
+ ),
162
+ );
163
+ }
164
+ lines.push(
165
+ "",
166
+ "Generated file freshness:",
167
+ `Status: ${result.generatedFilesFreshness.status}`,
168
+ result.generatedFilesFreshness.message,
169
+ `Refresh: ${result.generatedFilesFreshness.refreshCommand}`,
170
+ "",
171
+ "Repair actions:",
172
+ ...(result.repairActions.length ? result.repairActions.map((action) => `- ${action.command}`) : ["- none"]),
173
+ "",
174
+ "Validation commands:",
175
+ ...result.validationCommands.map((command) => `- ${command}`),
176
+ );
177
+ return `${lines.join("\n")}\n`;
178
+ };
179
+
70
180
  export interface AkanContextOptions {
71
181
  app?: string | null;
72
182
  module?: string | null;
@@ -74,17 +184,69 @@ export interface AkanContextOptions {
74
184
  }
75
185
 
76
186
  const generatedFiles = [
77
- "cnst.ts",
78
- "db.ts",
79
- "dict.ts",
80
- "option.ts",
81
- "sig.ts",
82
- "srv.ts",
83
- "st.ts",
84
- "useClient.ts",
85
- "useServer.ts",
187
+ "apps/*/client.ts",
188
+ "apps/*/server.ts",
189
+ "*/lib/cnst.ts",
190
+ "*/lib/db.ts",
191
+ "*/lib/dict.ts",
192
+ "*/lib/option.ts",
193
+ "*/lib/sig.ts",
194
+ "*/lib/srv.ts",
195
+ "*/lib/st.ts",
196
+ "*/lib/useClient.ts",
197
+ "*/lib/useServer.ts",
198
+ "*/lib/**/index.ts",
199
+ "*/ui/index.ts",
200
+ "*/webkit/index.ts",
201
+ "*/srvkit/index.ts",
202
+ "*/common/index.ts",
203
+ ];
204
+
205
+ const validationCommands = [
206
+ "akan sync <app-or-lib>",
207
+ "akan lint <app-or-lib-or-pkg>",
208
+ "akan typecheck <app-name>",
209
+ "akan test <app-or-lib-or-pkg>",
210
+ "akan build <app-name>",
211
+ "akan doctor --strict --format json",
86
212
  ];
87
213
 
214
+ const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
215
+ status: "unknown" as const,
216
+ message: "Run sync before validation so generated Akan files match the current source conventions.",
217
+ refreshCommand: "akan sync <app-or-lib>",
218
+ verifyingCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>"],
219
+ };
220
+
221
+ const repairAction = (
222
+ kind: RepairAction["kind"],
223
+ command: string,
224
+ reason: string,
225
+ safeToRun: boolean,
226
+ ): RepairAction => ({
227
+ kind,
228
+ command,
229
+ reason,
230
+ safeToRun,
231
+ });
232
+
233
+ const moduleShapeFiles = (module: AkanModuleContext) => {
234
+ if (module.kind === "service") {
235
+ return [`${module.name}.dictionary.ts`, `${module.name}.service.ts`, `${module.name}.signal.ts`];
236
+ }
237
+ if (module.kind === "scalar") return [`${module.name}.constant.ts`, `${module.name}.dictionary.ts`];
238
+ return [
239
+ `${module.name}.constant.ts`,
240
+ `${module.name}.dictionary.ts`,
241
+ `${module.name}.service.ts`,
242
+ `${module.name}.store.ts`,
243
+ `${module.name}.signal.ts`,
244
+ ];
245
+ };
246
+
247
+ const constantFieldNames = (content: string) =>
248
+ [...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
249
+
88
250
  const appRootAllowFiles = new Set([
89
251
  "akan.app.json",
90
252
  "akan.config.ts",
@@ -136,6 +298,135 @@ const safeReadJson = async <T>(filePath: string) => {
136
298
  }
137
299
  };
138
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
+
139
430
  const parseAbstractSummary = (
140
431
  relativePath: string,
141
432
  content: string | null,
@@ -283,28 +574,50 @@ export class AkanContextAnalyzer {
283
574
  libs,
284
575
  pkgs,
285
576
  generatedFiles,
286
- validationCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>", "akan start <app-name>"],
577
+ validationCommands,
287
578
  };
288
579
  }
289
580
 
290
581
  static async doctor(
291
582
  workspace: WorkspaceExecutor,
292
- { strict = false }: { strict?: boolean } = {},
583
+ {
584
+ strict = false,
585
+ runIdOrPlan = null,
586
+ changedFiles = [],
587
+ }: { strict?: boolean; runIdOrPlan?: string | null; changedFiles?: string[] } = {},
293
588
  ): Promise<AkanDoctorResult> {
294
589
  const context = await AkanContextAnalyzer.analyze(workspace);
590
+ const workflowPaths = await loadWorkflowContextPaths(workspace, runIdOrPlan, changedFiles);
295
591
  const diagnostics: AkanDiagnostic[] = [];
592
+ const repairActions: RepairAction[] = [
593
+ repairAction("generated", "akan repair generated --app <app-or-lib>", "Refresh generated Akan files.", true),
594
+ repairAction(
595
+ "format",
596
+ "akan repair format --target <app-or-lib-or-pkg>",
597
+ "Run the formatter/linter repair path.",
598
+ true,
599
+ ),
600
+ ];
296
601
 
297
602
  for (const app of context.apps) {
298
603
  const appPath = path.join(workspace.workspaceRoot, app.path);
299
604
  for (const entry of await safeReadDir(appPath)) {
300
605
  const allowed = entry.isDirectory() ? appRootAllowDirs.has(entry.name) : appRootAllowFiles.has(entry.name);
301
606
  if (!allowed) {
607
+ const action = repairAction(
608
+ "module-shape",
609
+ `akan repair module-shape --app ${app.name}`,
610
+ "Review app root shape and remove or move the unknown entry.",
611
+ false,
612
+ );
302
613
  diagnostics.push({
303
- severity: "warning",
614
+ severity: "error",
304
615
  code: "app-root-unknown-entry",
305
616
  path: `${app.path}/${entry.name}`,
306
617
  message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in app root: ${app.path}/${entry.name}`,
618
+ repairActions: [action],
307
619
  });
620
+ repairActions.push(action);
308
621
  }
309
622
  }
310
623
  }
@@ -312,21 +625,102 @@ export class AkanContextAnalyzer {
312
625
  for (const sys of [...context.apps, ...context.libs]) {
313
626
  for (const module of sys.modules) {
314
627
  if (!module.abstract.exists) {
628
+ const action = repairAction(
629
+ "module-shape",
630
+ `akan repair module-shape --app ${sys.name} --module ${module.name}`,
631
+ "Create the missing module abstract or inspect required source files.",
632
+ false,
633
+ );
315
634
  diagnostics.push({
316
635
  severity: strict ? "error" : "warning",
317
636
  code: "module-abstract-missing",
318
637
  path: module.abstract.path,
319
638
  message: `${capitalize(module.kind)} module ${sys.name}:${module.name} should include ${module.abstract.path}`,
639
+ repairActions: [action],
320
640
  });
641
+ repairActions.push(action);
642
+ }
643
+ const missingFiles = moduleShapeFiles(module).filter((filename) => !module.files.includes(filename));
644
+ if (missingFiles.length) {
645
+ const action = repairAction(
646
+ "module-shape",
647
+ `akan repair module-shape --app ${sys.name} --module ${module.name}`,
648
+ "Review missing required module source files.",
649
+ false,
650
+ );
651
+ diagnostics.push({
652
+ severity: "error",
653
+ code: "module-shape-invalid",
654
+ path: module.path,
655
+ message: `${capitalize(module.kind)} module ${sys.name}:${module.name} is missing required files: ${missingFiles.join(", ")}`,
656
+ repairActions: [action],
657
+ });
658
+ repairActions.push(action);
659
+ }
660
+ if (module.kind !== "service" && module.files.includes(`${module.name}.dictionary.ts`)) {
661
+ const constantPath = path.join(workspace.workspaceRoot, module.path, `${module.name}.constant.ts`);
662
+ const dictionaryPath = path.join(workspace.workspaceRoot, module.path, `${module.name}.dictionary.ts`);
663
+ const [constantContent, dictionaryContent] = await Promise.all([
664
+ safeReadText(constantPath),
665
+ safeReadText(dictionaryPath),
666
+ ]);
667
+ if (constantContent && dictionaryContent) {
668
+ for (const fieldName of constantFieldNames(constantContent)) {
669
+ if (new RegExp(`\\b${fieldName}\\s*:`).test(dictionaryContent)) continue;
670
+ const action = repairAction(
671
+ "dictionary",
672
+ `akan repair dictionary --app ${sys.name} --module ${module.name}`,
673
+ "Add missing dictionary labels for source constant fields.",
674
+ false,
675
+ );
676
+ diagnostics.push({
677
+ severity: "warning",
678
+ code: "dictionary-label-missing",
679
+ path: `${module.path}/${module.name}.dictionary.ts`,
680
+ message: `Dictionary labels for ${sys.name}:${module.name}.${fieldName} were not found.`,
681
+ repairActions: [action],
682
+ });
683
+ repairActions.push(action);
684
+ }
685
+ }
321
686
  }
322
687
  }
323
688
  }
324
689
 
325
- return { schemaVersion: 1, strict, diagnostics };
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");
701
+ return {
702
+ schemaVersion: 1,
703
+ repoName: context.repoName,
704
+ root: context.root,
705
+ strict,
706
+ status: scopedDiagnostics.some((diagnostic) => diagnostic.severity === "error") ? "failed" : "passed",
707
+ diagnostics: scopedDiagnostics,
708
+ generatedFiles: context.generatedFiles,
709
+ generatedFilesFreshness: await generatedFreshnessFromStates(workspace),
710
+ validationCommands: context.validationCommands,
711
+ repairActions,
712
+ ...(workflowPaths.length ? { baselineDiagnostics, workflowDiagnostics } : {}),
713
+ };
326
714
  }
327
715
 
328
- static findModules(context: AkanWorkspaceContext, moduleName?: string | null) {
329
- 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);
330
724
  return moduleName
331
725
  ? modules.filter((module) => module.name === moduleName || module.folderName === moduleName)
332
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
+ });