@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 +4 -12
- package/akanContext.ts +409 -15
- package/akanMcpContract.test.ts +37 -0
- package/akanMcpContract.ts +701 -0
- package/capacitorApp.test.ts +14 -8
- package/commandDecorators/command.ts +2 -1
- package/commandDecorators/commandDecorators.test.ts +13 -0
- package/devkitUtils.test.ts +2 -2
- package/executors.test.ts +21 -0
- package/executors.ts +22 -17
- package/getModelFileData.ts +5 -2
- package/index.ts +2 -0
- package/linter.ts +27 -74
- package/package.json +2 -2
- package/typecheck/typecheck.proc.ts +1 -2
- package/workflow/artifacts.ts +571 -0
- package/workflow/executor.ts +590 -0
- package/workflow/index.ts +11 -0
- 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/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
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
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
|
|
577
|
+
validationCommands,
|
|
287
578
|
};
|
|
288
579
|
}
|
|
289
580
|
|
|
290
581
|
static async doctor(
|
|
291
582
|
workspace: WorkspaceExecutor,
|
|
292
|
-
{
|
|
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: "
|
|
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
|
-
|
|
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(
|
|
329
|
-
|
|
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
|
+
});
|