@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 +195 -14
- package/akanMcpContract.test.ts +37 -0
- package/akanMcpContract.ts +701 -0
- package/devkitUtils.test.ts +2 -2
- package/executors.ts +22 -17
- package/getModelFileData.ts +5 -2
- package/index.ts +1 -0
- package/package.json +2 -2
- package/workflow/artifacts.ts +571 -0
- package/workflow/executor.ts +590 -0
- package/workflow/index.ts +11 -1089
- package/workflow/moduleIndex.test.ts +296 -0
- package/workflow/moduleIndex.ts +504 -0
- package/workflow/plan.ts +429 -0
- package/workflow/primitive.ts +59 -0
- package/workflow/render.ts +335 -0
- package/workflow/rolloutGate.test.ts +109 -0
- package/workflow/rolloutGate.ts +141 -0
- package/workflow/source.test.ts +62 -0
- package/workflow/source.ts +864 -0
- package/workflow/types.ts +475 -0
- package/workflow/uiPolicy.ts +45 -0
- package/workflow/utils.ts +23 -0
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
|
|
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
|
|
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
|
-
{
|
|
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:
|
|
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(
|
|
542
|
-
|
|
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
|
+
});
|