@akanjs/devkit 2.3.9-rc.7 → 2.3.9-rc.8
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/akanMcpContract.test.ts +37 -0
- package/akanMcpContract.ts +670 -0
- package/index.ts +1 -0
- package/package.json +2 -2
- package/workflow/artifacts.ts +58 -6
- package/workflow/executor.ts +208 -4
- package/workflow/index.ts +2 -0
- package/workflow/moduleIndex.test.ts +296 -0
- package/workflow/moduleIndex.ts +504 -0
- package/workflow/render.ts +4 -3
- package/workflow/rolloutGate.test.ts +109 -0
- package/workflow/rolloutGate.ts +138 -0
- package/workflow/source.test.ts +62 -0
- package/workflow/source.ts +463 -75
- package/workflow/types.ts +130 -0
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./aiEditor";
|
|
|
2
2
|
export * from "./akanApp";
|
|
3
3
|
export * from "./akanConfig";
|
|
4
4
|
export * from "./akanContext";
|
|
5
|
+
export * from "./akanMcpContract";
|
|
5
6
|
export * from "./applicationBuildReporter";
|
|
6
7
|
export * from "./applicationBuildRunner";
|
|
7
8
|
export * from "./applicationReleasePackager";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.9-rc.
|
|
3
|
+
"version": "2.3.9-rc.8",
|
|
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.
|
|
35
|
+
"akanjs": "2.3.9-rc.8",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|
package/workflow/artifacts.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
WorkflowDiagnostic,
|
|
11
11
|
WorkflowFailureScope,
|
|
12
12
|
WorkflowKnownBlocker,
|
|
13
|
+
WorkflowNextActionCode,
|
|
14
|
+
WorkflowOverallStatus,
|
|
13
15
|
WorkflowPlan,
|
|
14
16
|
WorkflowRunArtifact,
|
|
15
17
|
WorkflowRunSource,
|
|
@@ -19,6 +21,37 @@ import type {
|
|
|
19
21
|
} from "./types";
|
|
20
22
|
import { commandStatus, jsonText, uniqueBy, workflowStatus } from "./utils";
|
|
21
23
|
|
|
24
|
+
const sourceChangeBlocked = (diagnostics: readonly WorkflowDiagnostic[]) =>
|
|
25
|
+
diagnostics.some(
|
|
26
|
+
(diagnostic) =>
|
|
27
|
+
diagnostic.severity === "error" &&
|
|
28
|
+
(diagnostic.failureScope === "source-change" ||
|
|
29
|
+
!diagnostic.failureScope ||
|
|
30
|
+
diagnostic.failureScope === "unknown"),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const inferNextActionCode = (
|
|
34
|
+
action: { action?: WorkflowNextActionCode; command: string },
|
|
35
|
+
diagnostics: readonly WorkflowDiagnostic[],
|
|
36
|
+
): WorkflowNextActionCode => {
|
|
37
|
+
if (action.action) return action.action;
|
|
38
|
+
if (sourceChangeBlocked(diagnostics) && action.command.startsWith("akan workflow explain")) return "blocked";
|
|
39
|
+
if (action.command.startsWith("akan workflow repair")) return "repair";
|
|
40
|
+
if (action.command.startsWith("akan workflow explain")) return "manual-review";
|
|
41
|
+
if (action.command.startsWith("akan ")) return "validate";
|
|
42
|
+
return "answer";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const nextActionPriority = (
|
|
46
|
+
action: { action?: WorkflowNextActionCode },
|
|
47
|
+
diagnostics: readonly WorkflowDiagnostic[],
|
|
48
|
+
) => {
|
|
49
|
+
const priority = sourceChangeBlocked(diagnostics)
|
|
50
|
+
? { blocked: 0, repair: 1, "manual-review": 2, validate: 3, answer: 4 }
|
|
51
|
+
: { "manual-review": 0, validate: 1, repair: 2, blocked: 3, answer: 4 };
|
|
52
|
+
return priority[action.action ?? "answer"];
|
|
53
|
+
};
|
|
54
|
+
|
|
22
55
|
export const createWorkflowApplyReport = ({
|
|
23
56
|
workflow,
|
|
24
57
|
mode,
|
|
@@ -52,11 +85,20 @@ export const createWorkflowApplyReport = ({
|
|
|
52
85
|
recommendations?: WorkflowApplyReport["recommendations"];
|
|
53
86
|
}): WorkflowApplyReport => {
|
|
54
87
|
const validationCommands = recommendedValidationCommands ?? commands;
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
const nextActionsWithIntent = nextActions.map((action) => ({
|
|
89
|
+
...action,
|
|
90
|
+
action: inferNextActionCode(action, diagnostics),
|
|
91
|
+
}));
|
|
92
|
+
if (sourceChangeBlocked(diagnostics) && !nextActionsWithIntent.some((action) => action.action === "blocked")) {
|
|
93
|
+
nextActionsWithIntent.unshift({
|
|
94
|
+
command: `akan workflow explain ${workflow}`,
|
|
95
|
+
reason: "Review source-change blockers before running validation.",
|
|
96
|
+
action: "blocked",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const orderedNextActions = uniqueBy(nextActionsWithIntent, (action) => action.command).sort(
|
|
100
|
+
(left, right) => nextActionPriority(left, diagnostics) - nextActionPriority(right, diagnostics),
|
|
101
|
+
);
|
|
60
102
|
return {
|
|
61
103
|
schemaVersion: 1,
|
|
62
104
|
workflow,
|
|
@@ -251,7 +293,17 @@ const createKnownBlockers = (
|
|
|
251
293
|
const createValidationStatuses = (
|
|
252
294
|
commands: readonly WorkflowValidationCommandResult[],
|
|
253
295
|
diagnostics: readonly WorkflowDiagnostic[],
|
|
254
|
-
)
|
|
296
|
+
): {
|
|
297
|
+
sourceStatus: WorkflowValidationStatus;
|
|
298
|
+
workspaceStatus: WorkflowValidationStatus;
|
|
299
|
+
overallStatus: WorkflowOverallStatus;
|
|
300
|
+
summary: {
|
|
301
|
+
sourceChange: WorkflowValidationStatus;
|
|
302
|
+
generatedSync: WorkflowValidationStatus;
|
|
303
|
+
workspaceConfig: WorkflowValidationStatus;
|
|
304
|
+
environment: WorkflowValidationStatus;
|
|
305
|
+
};
|
|
306
|
+
} => {
|
|
255
307
|
const scopes = [...failedCommandScopes(commands), ...errorDiagnosticScopes(diagnostics)];
|
|
256
308
|
const sourceStatus = statusForScope(commands, diagnostics, scopes, "source-change");
|
|
257
309
|
const workspaceStatus =
|
package/workflow/executor.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { capitalize } from "akanjs/common";
|
|
2
1
|
import ts from "typescript";
|
|
2
|
+
import type { AkanModuleContext } from "../akanContext";
|
|
3
3
|
import type { Sys, Workspace } from "../commandDecorators";
|
|
4
4
|
import { AppExecutor, LibExecutor } from "../executors";
|
|
5
5
|
import { createWorkflowApplyReport, workflowCommandsForPlan } from "./artifacts";
|
|
6
|
-
import {
|
|
6
|
+
import { buildAkanModuleContextIndex } from "./moduleIndex";
|
|
7
|
+
import {
|
|
8
|
+
insertionIndexForFieldOrder,
|
|
9
|
+
inspectConstantStructure,
|
|
10
|
+
inspectDictionaryStructure,
|
|
11
|
+
moduleComponentName,
|
|
12
|
+
moduleSourcePaths,
|
|
13
|
+
normalizeFieldType,
|
|
14
|
+
} from "./source";
|
|
7
15
|
import type {
|
|
8
16
|
PrimitiveChangedFile,
|
|
9
17
|
PrimitiveGeneratedFile,
|
|
@@ -47,6 +55,14 @@ const postApplyDiagnostic = (code: string, message: string, target: string): Wor
|
|
|
47
55
|
context: { target },
|
|
48
56
|
});
|
|
49
57
|
|
|
58
|
+
const postApplyWarning = (code: string, message: string, target: string): WorkflowDiagnostic => ({
|
|
59
|
+
severity: "warning",
|
|
60
|
+
code,
|
|
61
|
+
message,
|
|
62
|
+
failureScope: "source-change",
|
|
63
|
+
context: { target },
|
|
64
|
+
});
|
|
65
|
+
|
|
50
66
|
const sourceKindForPath = (filePath: string) => {
|
|
51
67
|
if (filePath.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
52
68
|
if (filePath.endsWith(".ts")) return ts.ScriptKind.TS;
|
|
@@ -81,7 +97,8 @@ const checkTypeScriptSyntax = async (workspace: Workspace, filePath: string) =>
|
|
|
81
97
|
if (!scriptKind) return null;
|
|
82
98
|
const content = await workspace.readFile(filePath);
|
|
83
99
|
const source = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
84
|
-
const diagnostic =
|
|
100
|
+
const diagnostic =
|
|
101
|
+
((source as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] }).parseDiagnostics ?? [])[0];
|
|
85
102
|
if (!diagnostic) return null;
|
|
86
103
|
const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
|
|
87
104
|
const location = position ? `:${position.line + 1}:${position.character + 1}` : "";
|
|
@@ -136,6 +153,188 @@ const checkRecommendationPath = async (
|
|
|
136
153
|
};
|
|
137
154
|
};
|
|
138
155
|
|
|
156
|
+
const sourceChangeError = (diagnostics: readonly WorkflowDiagnostic[]) =>
|
|
157
|
+
diagnostics.some(
|
|
158
|
+
(diagnostic) =>
|
|
159
|
+
diagnostic.severity === "error" &&
|
|
160
|
+
(diagnostic.failureScope === "source-change" ||
|
|
161
|
+
!diagnostic.failureScope ||
|
|
162
|
+
diagnostic.failureScope === "unknown"),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const fieldCount = (fields: readonly string[], fieldName: string) =>
|
|
166
|
+
fields.filter((field) => field === fieldName).length;
|
|
167
|
+
|
|
168
|
+
const fieldOrderValid = (fields: readonly string[], fieldName: string) => {
|
|
169
|
+
const actualIndex = fields.indexOf(fieldName);
|
|
170
|
+
if (actualIndex < 0 || fields.lastIndexOf(fieldName) !== actualIndex) return false;
|
|
171
|
+
const fieldsWithoutRequested = fields.filter((_, index) => index !== actualIndex);
|
|
172
|
+
return insertionIndexForFieldOrder(fieldsWithoutRequested, fieldName) === actualIndex;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const workflowModuleContext = async (workspace: Workspace, plan: WorkflowPlan): Promise<AkanModuleContext | null> => {
|
|
176
|
+
const app = workflowStringInput(plan.inputs.app);
|
|
177
|
+
const moduleName = workflowStringInput(plan.inputs.module);
|
|
178
|
+
if (!app || !moduleName) return null;
|
|
179
|
+
const [apps, libs] = await workspace.getSyss();
|
|
180
|
+
const sysType = apps.includes(app) ? "app" : libs.includes(app) ? "lib" : null;
|
|
181
|
+
if (!sysType) return null;
|
|
182
|
+
const modulePath = `${sysType}s/${app}/lib/${moduleName}`;
|
|
183
|
+
const files = await workspace.readdir(modulePath);
|
|
184
|
+
const abstractPath = `${modulePath}/${moduleName}.abstract.md`;
|
|
185
|
+
return {
|
|
186
|
+
kind: "domain",
|
|
187
|
+
name: moduleName,
|
|
188
|
+
folderName: moduleName,
|
|
189
|
+
sysName: app,
|
|
190
|
+
sysType,
|
|
191
|
+
path: modulePath,
|
|
192
|
+
abstract: {
|
|
193
|
+
path: abstractPath,
|
|
194
|
+
exists: files.includes(`${moduleName}.abstract.md`),
|
|
195
|
+
headings: [],
|
|
196
|
+
},
|
|
197
|
+
files,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const structureCheck = (
|
|
202
|
+
code: string,
|
|
203
|
+
target: string,
|
|
204
|
+
status: WorkflowPostApplyCheck["status"],
|
|
205
|
+
message: string,
|
|
206
|
+
): WorkflowPostApplyCheck => ({
|
|
207
|
+
code,
|
|
208
|
+
target,
|
|
209
|
+
status,
|
|
210
|
+
message,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const checkAddFieldStructure = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowStepResult> => {
|
|
214
|
+
if (plan.workflow !== "add-field" && plan.workflow !== "add-enum-field") return {};
|
|
215
|
+
const app = workflowStringInput(plan.inputs.app);
|
|
216
|
+
const moduleName = workflowStringInput(plan.inputs.module);
|
|
217
|
+
const fieldName = workflowStringInput(plan.inputs.field);
|
|
218
|
+
const typeName = workflowStringInput(plan.inputs.type);
|
|
219
|
+
if (!app || !moduleName || !fieldName || !typeName) return {};
|
|
220
|
+
|
|
221
|
+
const moduleContext = await workflowModuleContext(workspace, plan);
|
|
222
|
+
if (!moduleContext) return {};
|
|
223
|
+
|
|
224
|
+
const paths = moduleSourcePaths(moduleName);
|
|
225
|
+
const constantPath = `${moduleContext.path}/${paths.constant.replace(`lib/${moduleName}/`, "")}`;
|
|
226
|
+
const dictionaryPath = `${moduleContext.path}/${paths.dictionary.replace(`lib/${moduleName}/`, "")}`;
|
|
227
|
+
const moduleClassName = moduleComponentName(moduleName);
|
|
228
|
+
const inputClassName = `${moduleClassName}Input`;
|
|
229
|
+
const index = await buildAkanModuleContextIndex(workspace, moduleContext, { field: fieldName });
|
|
230
|
+
const diagnostics: WorkflowDiagnostic[] = [];
|
|
231
|
+
const postApplyChecks: WorkflowPostApplyCheck[] = [];
|
|
232
|
+
|
|
233
|
+
const constantContent = await workspace.readFile(constantPath);
|
|
234
|
+
const constantStructure = inspectConstantStructure(constantContent, inputClassName, moduleClassName);
|
|
235
|
+
const constantNames = constantStructure.fields.map((field) => field.name);
|
|
236
|
+
const requestedConstantFields = constantStructure.fields.filter((field) => field.name === fieldName);
|
|
237
|
+
const normalizedType = typeName.toLowerCase() === "enum" ? typeName : normalizeFieldType(typeName);
|
|
238
|
+
const constantFailures = [
|
|
239
|
+
!constantStructure.parseValid ? "constant file does not parse" : null,
|
|
240
|
+
!constantStructure.inputObjectFound ? `${inputClassName} via builder object was not found` : null,
|
|
241
|
+
requestedConstantFields.length !== 1
|
|
242
|
+
? `field "${fieldName}" appears ${requestedConstantFields.length} time(s) in ${inputClassName}`
|
|
243
|
+
: null,
|
|
244
|
+
constantStructure.builderName &&
|
|
245
|
+
requestedConstantFields[0]?.expressionBuilder &&
|
|
246
|
+
requestedConstantFields[0].expressionBuilder !== constantStructure.builderName
|
|
247
|
+
? `field "${fieldName}" uses builder "${requestedConstantFields[0].expressionBuilder}" instead of "${constantStructure.builderName}"`
|
|
248
|
+
: null,
|
|
249
|
+
!constantStructure.builderName ? `${inputClassName} via builder parameter was not found` : null,
|
|
250
|
+
(normalizedType === "Int" || normalizedType === "Float") && !constantStructure.baseImports.includes(normalizedType)
|
|
251
|
+
? `missing ${normalizedType} import from "akanjs/base"`
|
|
252
|
+
: null,
|
|
253
|
+
workflowBooleanInput(plan.inputs.includeInLight) === true &&
|
|
254
|
+
fieldCount(constantStructure.lightProjectionFields, fieldName) !== 1
|
|
255
|
+
? `field "${fieldName}" is not present exactly once in Light${moduleClassName}`
|
|
256
|
+
: null,
|
|
257
|
+
...index.diagnostics
|
|
258
|
+
.filter((diagnostic) => diagnostic.severity === "error" && diagnostic.context?.paths?.includes(constantPath))
|
|
259
|
+
.map((diagnostic) => diagnostic.message),
|
|
260
|
+
].filter((failure): failure is string => failure !== null);
|
|
261
|
+
const constantValid = constantFailures.length === 0;
|
|
262
|
+
postApplyChecks.push(
|
|
263
|
+
structureCheck(
|
|
264
|
+
constantValid ? "workflow-post-apply-constant-shape-valid" : "workflow-post-apply-structure-invalid",
|
|
265
|
+
constantPath,
|
|
266
|
+
constantValid ? "passed" : "failed",
|
|
267
|
+
constantValid
|
|
268
|
+
? `${inputClassName} keeps via structure, builder usage, imports, and requested field presence.`
|
|
269
|
+
: constantFailures.join(" "),
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
if (!constantValid) {
|
|
273
|
+
diagnostics.push(
|
|
274
|
+
postApplyDiagnostic("workflow-post-apply-structure-invalid", constantFailures.join(" "), constantPath),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const dictionaryContent = await workspace.readFile(dictionaryPath);
|
|
279
|
+
const dictionaryStructure = inspectDictionaryStructure(dictionaryContent, moduleClassName);
|
|
280
|
+
const dictionaryFailures = [
|
|
281
|
+
!dictionaryStructure.parseValid ? "dictionary file does not parse" : null,
|
|
282
|
+
!dictionaryStructure.modelObjectFound ? `.model<${moduleClassName}> object was not found` : null,
|
|
283
|
+
dictionaryStructure.modelObjectFound && !dictionaryStructure.chainOrderValid
|
|
284
|
+
? `.model(), .slice(), .enum(), .error(), and .translate() chain order is broken: ${dictionaryStructure.chainMethods.join(
|
|
285
|
+
" -> ",
|
|
286
|
+
)}`
|
|
287
|
+
: null,
|
|
288
|
+
fieldCount(dictionaryStructure.fields, fieldName) !== 1
|
|
289
|
+
? `field "${fieldName}" appears ${fieldCount(dictionaryStructure.fields, fieldName)} time(s) in .model<${moduleClassName}>`
|
|
290
|
+
: null,
|
|
291
|
+
...index.diagnostics
|
|
292
|
+
.filter((diagnostic) => diagnostic.severity === "error" && diagnostic.context?.paths?.includes(dictionaryPath))
|
|
293
|
+
.map((diagnostic) => diagnostic.message),
|
|
294
|
+
].filter((failure): failure is string => failure !== null);
|
|
295
|
+
const dictionaryValid = dictionaryFailures.length === 0;
|
|
296
|
+
postApplyChecks.push(
|
|
297
|
+
structureCheck(
|
|
298
|
+
dictionaryValid ? "workflow-post-apply-dictionary-shape-valid" : "workflow-post-apply-structure-invalid",
|
|
299
|
+
dictionaryPath,
|
|
300
|
+
dictionaryValid ? "passed" : "failed",
|
|
301
|
+
dictionaryValid
|
|
302
|
+
? `.model<${moduleClassName}> keeps the requested field inside the model object and preserves dictionary chain order.`
|
|
303
|
+
: dictionaryFailures.join(" "),
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
if (!dictionaryValid) {
|
|
307
|
+
diagnostics.push(
|
|
308
|
+
postApplyDiagnostic("workflow-post-apply-structure-invalid", dictionaryFailures.join(" "), dictionaryPath),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const constantOrderValid = fieldOrderValid(constantNames, fieldName);
|
|
313
|
+
const dictionaryOrderValid = fieldOrderValid(dictionaryStructure.fields, fieldName);
|
|
314
|
+
const orderValid = constantOrderValid && dictionaryOrderValid;
|
|
315
|
+
postApplyChecks.push(
|
|
316
|
+
structureCheck(
|
|
317
|
+
"workflow-post-apply-field-order-valid",
|
|
318
|
+
`${constantPath}, ${dictionaryPath}`,
|
|
319
|
+
orderValid ? "passed" : "failed",
|
|
320
|
+
orderValid
|
|
321
|
+
? `Field "${fieldName}" follows the shared priority ordering policy.`
|
|
322
|
+
: `Field "${fieldName}" is present but does not match the shared priority ordering policy.`,
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
if (!orderValid) {
|
|
326
|
+
diagnostics.push(
|
|
327
|
+
postApplyWarning(
|
|
328
|
+
"workflow-post-apply-field-order-mismatch",
|
|
329
|
+
`Field "${fieldName}" is present but does not match the shared priority ordering policy.`,
|
|
330
|
+
`${constantPath}, ${dictionaryPath}`,
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { diagnostics, postApplyChecks };
|
|
336
|
+
};
|
|
337
|
+
|
|
139
338
|
const resolveWorkflowSys = async (workspace: Workspace, target: string | null): Promise<Sys | null> => {
|
|
140
339
|
if (!target) return null;
|
|
141
340
|
const [apps, libs] = await workspace.getSyss();
|
|
@@ -173,7 +372,7 @@ const addFieldUiSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult =>
|
|
|
173
372
|
const policy = addFieldUiPolicyForType(typeName ?? "String");
|
|
174
373
|
const surfaces = workflowStringArrayInput(plan.inputs.surfaces);
|
|
175
374
|
const templateRequested = surfaces?.includes("template") ?? false;
|
|
176
|
-
const moduleClassName =
|
|
375
|
+
const moduleClassName = moduleComponentName(module);
|
|
177
376
|
const target = `${app ? `apps/${app}` : "*"}/${moduleSourcePaths(module).template}`;
|
|
178
377
|
return {
|
|
179
378
|
recommendations: [
|
|
@@ -362,6 +561,11 @@ export class WorkflowExecutor {
|
|
|
362
561
|
postApplyChecks.push(...(result.postApplyChecks ?? []));
|
|
363
562
|
diagnostics.push(...(result.diagnostics ?? []));
|
|
364
563
|
}
|
|
564
|
+
if (!sourceChangeError(diagnostics)) {
|
|
565
|
+
const result = await checkAddFieldStructure(this.workspace, plan);
|
|
566
|
+
postApplyChecks.push(...(result.postApplyChecks ?? []));
|
|
567
|
+
diagnostics.push(...(result.diagnostics ?? []));
|
|
568
|
+
}
|
|
365
569
|
const recommendationDiagnostics = await Promise.all(
|
|
366
570
|
recommendations.map((recommendation) => checkRecommendationPath(this.workspace as Workspace, recommendation)),
|
|
367
571
|
);
|
package/workflow/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from "./artifacts";
|
|
2
2
|
export * from "./executor";
|
|
3
|
+
export * from "./moduleIndex";
|
|
3
4
|
export * from "./plan";
|
|
4
5
|
export * from "./primitive";
|
|
5
6
|
export * from "./render";
|
|
7
|
+
export * from "./rolloutGate";
|
|
6
8
|
export * from "./source";
|
|
7
9
|
export * from "./types";
|
|
8
10
|
export * from "./uiPolicy";
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { AkanModuleContext } from "../akanContext";
|
|
6
|
+
import type { Workspace } from "../commandDecorators";
|
|
7
|
+
import { buildAkanModuleContextIndex } from "./moduleIndex";
|
|
8
|
+
|
|
9
|
+
const tempRoots: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const makeWorkspace = async () => {
|
|
16
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-module-index-"));
|
|
17
|
+
tempRoots.push(root);
|
|
18
|
+
return {
|
|
19
|
+
root,
|
|
20
|
+
workspace: { workspaceRoot: root } as Workspace,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const writeText = async (filePath: string, content: string) => {
|
|
25
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
26
|
+
await writeFile(filePath, content);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const moduleContext = (files: string[], name = "post"): AkanModuleContext => ({
|
|
30
|
+
kind: "domain",
|
|
31
|
+
name,
|
|
32
|
+
folderName: name,
|
|
33
|
+
sysName: "demo",
|
|
34
|
+
sysType: "app",
|
|
35
|
+
path: `apps/demo/lib/${name}`,
|
|
36
|
+
abstract: { path: `apps/demo/lib/${name}/${name}.abstract.md`, exists: true, headings: [] },
|
|
37
|
+
files,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("buildAkanModuleContextIndex", () => {
|
|
41
|
+
test("indexes constant, dictionary model, projection, spans, and partial presence without source bodies", async () => {
|
|
42
|
+
const { root, workspace } = await makeWorkspace();
|
|
43
|
+
const modulePath = path.join(root, "apps/demo/lib/post");
|
|
44
|
+
await writeText(
|
|
45
|
+
path.join(modulePath, "post.constant.ts"),
|
|
46
|
+
`
|
|
47
|
+
import { Int } from "akanjs/base";
|
|
48
|
+
import { via } from "akanjs/constant";
|
|
49
|
+
|
|
50
|
+
export class PostInput extends via((_field) => ({
|
|
51
|
+
title: _field(String),
|
|
52
|
+
count: _field(Int, { default: 0 }),
|
|
53
|
+
metadata: _field({ nested: ["secret"] }),
|
|
54
|
+
})) {}
|
|
55
|
+
|
|
56
|
+
export class PostObject extends via(PostInput, (field) => ({})) {}
|
|
57
|
+
export class LightPost extends via(PostObject, ["title"] as const, (resolve) => ({})) {}
|
|
58
|
+
export class Post extends via(PostObject, LightPost, (resolve) => ({})) {}
|
|
59
|
+
`,
|
|
60
|
+
);
|
|
61
|
+
await writeText(
|
|
62
|
+
path.join(modulePath, "post.dictionary.ts"),
|
|
63
|
+
`
|
|
64
|
+
import { modelDictionary } from "akanjs/dictionary";
|
|
65
|
+
import type { Post } from "./post.constant";
|
|
66
|
+
|
|
67
|
+
export const dictionary = modelDictionary(["en", "ko"])
|
|
68
|
+
.model<Post>((t) => ({
|
|
69
|
+
title: t(["Title", "제목"]),
|
|
70
|
+
}))
|
|
71
|
+
.slice<PostSlice>((fn) => ({
|
|
72
|
+
inPublic: fn(["Post In Public", "Post 공개"]).arg((t) => ({
|
|
73
|
+
ignored: t(["Ignored", "Ignored"]),
|
|
74
|
+
})),
|
|
75
|
+
}))
|
|
76
|
+
.translate({});
|
|
77
|
+
`,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const index = await buildAkanModuleContextIndex(
|
|
81
|
+
workspace,
|
|
82
|
+
moduleContext(["post.abstract.md", "post.constant.ts", "post.dictionary.ts"]),
|
|
83
|
+
{ field: "count" },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(index).toMatchObject({
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
app: "demo",
|
|
89
|
+
module: "post",
|
|
90
|
+
moduleClassName: "Post",
|
|
91
|
+
constant: { inputClassName: "PostInput", builderName: "_field" },
|
|
92
|
+
dictionary: { modelClassName: "Post", translatorName: "t" },
|
|
93
|
+
});
|
|
94
|
+
expect(index.constant?.fields.map((field) => [field.name, field.order, field.typeSummary])).toEqual([
|
|
95
|
+
["title", 0, "_field(String)"],
|
|
96
|
+
["count", 1, "_field(Int)"],
|
|
97
|
+
["metadata", 2, "_field(object-literal)"],
|
|
98
|
+
]);
|
|
99
|
+
expect(index.dictionary?.fields.map((field) => field.name)).toEqual(["title"]);
|
|
100
|
+
expect(index.dictionary?.fields.map((field) => field.name)).not.toContain("ignored");
|
|
101
|
+
expect(index.constant?.lightProjection?.fields.map((field) => field.name)).toEqual(["title"]);
|
|
102
|
+
expect(index.constant?.fields[0]?.sourceSpan).toMatchObject({
|
|
103
|
+
file: "apps/demo/lib/post/post.constant.ts",
|
|
104
|
+
startLine: expect.any(Number),
|
|
105
|
+
endLine: expect.any(Number),
|
|
106
|
+
startOffset: expect.any(Number),
|
|
107
|
+
endOffset: expect.any(Number),
|
|
108
|
+
});
|
|
109
|
+
expect(index.fieldPresence.find((field) => field.name === "count")).toMatchObject({
|
|
110
|
+
requested: true,
|
|
111
|
+
constant: true,
|
|
112
|
+
dictionary: false,
|
|
113
|
+
lightProjection: false,
|
|
114
|
+
});
|
|
115
|
+
expect(index.diagnostics.map((diagnostic) => diagnostic.code)).toContain("module-index-field-presence-partial");
|
|
116
|
+
expect(JSON.stringify(index)).not.toContain("export class");
|
|
117
|
+
expect(JSON.stringify(index)).not.toContain("modelDictionary");
|
|
118
|
+
expect(JSON.stringify(index)).not.toContain("secret");
|
|
119
|
+
expect(JSON.stringify(index)).not.toContain("제목");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("reports missing and casing mismatch diagnostics with expected and actual paths", async () => {
|
|
123
|
+
const { root, workspace } = await makeWorkspace();
|
|
124
|
+
const modulePath = path.join(root, "apps/demo/lib/post");
|
|
125
|
+
await writeText(path.join(modulePath, "Post.Constant.ts"), "export class PostInput {}\n");
|
|
126
|
+
|
|
127
|
+
const index = await buildAkanModuleContextIndex(workspace, moduleContext(["Post.Constant.ts"]));
|
|
128
|
+
|
|
129
|
+
expect(index.files.find((file) => file.kind === "constant")).toMatchObject({
|
|
130
|
+
path: "apps/demo/lib/post/Post.Constant.ts",
|
|
131
|
+
expectedPath: "apps/demo/lib/post/post.constant.ts",
|
|
132
|
+
casing: "mismatch",
|
|
133
|
+
present: true,
|
|
134
|
+
});
|
|
135
|
+
expect(index.files.find((file) => file.kind === "dictionary")).toMatchObject({
|
|
136
|
+
expectedPath: "apps/demo/lib/post/post.dictionary.ts",
|
|
137
|
+
casing: "missing",
|
|
138
|
+
present: false,
|
|
139
|
+
});
|
|
140
|
+
expect(index.diagnostics.map((diagnostic) => diagnostic.code)).toContain("module-index-file-casing-mismatch");
|
|
141
|
+
expect(index.diagnostics.map((diagnostic) => diagnostic.code)).toContain("module-index-file-missing");
|
|
142
|
+
expect(index.diagnostics.flatMap((diagnostic) => diagnostic.context?.paths ?? [])).not.toContain(
|
|
143
|
+
"apps/demo/lib/post/Post.Template.tsx",
|
|
144
|
+
);
|
|
145
|
+
expect(index.diagnostics.flatMap((diagnostic) => diagnostic.context?.paths ?? [])).not.toContain(
|
|
146
|
+
"apps/demo/lib/post/post.service.ts",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("indexes field builder callbacks, dictionary block returns, and pascal module names", async () => {
|
|
151
|
+
const { root, workspace } = await makeWorkspace();
|
|
152
|
+
const modulePath = path.join(root, "apps/demo/lib/blog-post");
|
|
153
|
+
await writeText(
|
|
154
|
+
path.join(modulePath, "blog-post.constant.ts"),
|
|
155
|
+
`
|
|
156
|
+
import { via } from "akanjs/constant";
|
|
157
|
+
|
|
158
|
+
export class BlogPostInput extends via((field) => ({
|
|
159
|
+
title: field(String),
|
|
160
|
+
})) {}
|
|
161
|
+
|
|
162
|
+
export class BlogPostObject extends via(BlogPostInput, (field) => ({})) {}
|
|
163
|
+
export class LightBlogPost extends via(BlogPostObject, ["title"] as const, (resolve) => ({})) {}
|
|
164
|
+
export class BlogPost extends via(BlogPostObject, LightBlogPost, (resolve) => ({})) {}
|
|
165
|
+
`,
|
|
166
|
+
);
|
|
167
|
+
await writeText(
|
|
168
|
+
path.join(modulePath, "blog-post.dictionary.ts"),
|
|
169
|
+
`
|
|
170
|
+
import { modelDictionary } from "akanjs/dictionary";
|
|
171
|
+
import type { BlogPost } from "./blog-post.constant";
|
|
172
|
+
|
|
173
|
+
export const dictionary = modelDictionary(["en"]).model<BlogPost>((t) => {
|
|
174
|
+
return {
|
|
175
|
+
title: t(["Title"]),
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const index = await buildAkanModuleContextIndex(
|
|
182
|
+
workspace,
|
|
183
|
+
moduleContext(["blog-post.abstract.md", "blog-post.constant.ts", "blog-post.dictionary.ts"], "blog-post"),
|
|
184
|
+
{ field: "title" },
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(index.moduleClassName).toBe("BlogPost");
|
|
188
|
+
expect(index.constant).toMatchObject({ inputClassName: "BlogPostInput", builderName: "field" });
|
|
189
|
+
expect(index.constant?.fields.map((field) => [field.name, field.typeSummary])).toEqual([
|
|
190
|
+
["title", "field(String)"],
|
|
191
|
+
]);
|
|
192
|
+
expect(index.dictionary).toMatchObject({ modelClassName: "BlogPost", translatorName: "t" });
|
|
193
|
+
expect(index.dictionary?.fields.map((field) => field.name)).toEqual(["title"]);
|
|
194
|
+
expect(index.constant?.lightProjection?.fields.map((field) => field.name)).toEqual(["title"]);
|
|
195
|
+
expect(index.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain(
|
|
196
|
+
"module-index-dictionary-model-missing",
|
|
197
|
+
);
|
|
198
|
+
expect(JSON.stringify(index)).not.toContain("return {");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("reports parse and unsupported constant shape diagnostics", async () => {
|
|
202
|
+
const validDictionary = `
|
|
203
|
+
import { modelDictionary } from "akanjs/dictionary";
|
|
204
|
+
import type { Post } from "./post.constant";
|
|
205
|
+
|
|
206
|
+
export const dictionary = modelDictionary(["en"]).model<Post>((t) => ({}));
|
|
207
|
+
`;
|
|
208
|
+
const cases = [
|
|
209
|
+
{
|
|
210
|
+
name: "invalid-typescript",
|
|
211
|
+
constant: "export class PostInput extends via((field) => ({ title: field(String), })) {\n",
|
|
212
|
+
expectedCode: "module-index-typescript-parse-error",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "missing-input",
|
|
216
|
+
constant: "export class Post extends via(() => ({})) {}\n",
|
|
217
|
+
expectedCode: "module-index-constant-input-missing",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "missing-via",
|
|
221
|
+
constant: "export class PostInput {}\n",
|
|
222
|
+
expectedCode: "module-index-constant-via-missing",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "malformed-via",
|
|
226
|
+
constant: "export class PostInput extends via(PostObject) {}\n",
|
|
227
|
+
expectedCode: "module-index-constant-builder-missing",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "missing-builder-object",
|
|
231
|
+
constant: "export class PostInput extends via((field) => field(String)) {}\n",
|
|
232
|
+
expectedCode: "module-index-constant-builder-object-missing",
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const item of cases) {
|
|
237
|
+
const { root, workspace } = await makeWorkspace();
|
|
238
|
+
const modulePath = path.join(root, "apps/demo/lib/post");
|
|
239
|
+
await writeText(path.join(modulePath, "post.constant.ts"), item.constant);
|
|
240
|
+
await writeText(path.join(modulePath, "post.dictionary.ts"), validDictionary);
|
|
241
|
+
|
|
242
|
+
const index = await buildAkanModuleContextIndex(
|
|
243
|
+
workspace,
|
|
244
|
+
moduleContext(["post.abstract.md", "post.constant.ts", "post.dictionary.ts"]),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(
|
|
248
|
+
index.diagnostics.map((diagnostic) => diagnostic.code),
|
|
249
|
+
item.name,
|
|
250
|
+
).toContain(item.expectedCode);
|
|
251
|
+
expect(JSON.stringify(index), item.name).not.toContain(item.constant.trim());
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("reports unsupported dictionary model callback shapes", async () => {
|
|
256
|
+
const validConstant = `
|
|
257
|
+
import { via } from "akanjs/constant";
|
|
258
|
+
|
|
259
|
+
export class PostInput extends via((field) => ({})) {}
|
|
260
|
+
`;
|
|
261
|
+
const cases = [
|
|
262
|
+
{
|
|
263
|
+
name: "missing-callback",
|
|
264
|
+
dictionary: 'export const dictionary = modelDictionary(["en"]).model<Post>();\n',
|
|
265
|
+
expectedCode: "module-index-dictionary-builder-missing",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: "missing-object-return",
|
|
269
|
+
dictionary: 'export const dictionary = modelDictionary(["en"]).model<Post>((t) => t(["Title"]));\n',
|
|
270
|
+
expectedCode: "module-index-dictionary-builder-object-missing",
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
for (const item of cases) {
|
|
275
|
+
const { root, workspace } = await makeWorkspace();
|
|
276
|
+
const modulePath = path.join(root, "apps/demo/lib/post");
|
|
277
|
+
await writeText(path.join(modulePath, "post.constant.ts"), validConstant);
|
|
278
|
+
await writeText(path.join(modulePath, "post.dictionary.ts"), item.dictionary);
|
|
279
|
+
|
|
280
|
+
const index = await buildAkanModuleContextIndex(
|
|
281
|
+
workspace,
|
|
282
|
+
moduleContext(["post.abstract.md", "post.constant.ts", "post.dictionary.ts"]),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(
|
|
286
|
+
index.diagnostics.map((diagnostic) => diagnostic.code),
|
|
287
|
+
item.name,
|
|
288
|
+
).toContain(item.expectedCode);
|
|
289
|
+
expect(
|
|
290
|
+
index.diagnostics.map((diagnostic) => diagnostic.code),
|
|
291
|
+
item.name,
|
|
292
|
+
).not.toContain("module-index-dictionary-model-missing");
|
|
293
|
+
expect(JSON.stringify(index), item.name).not.toContain(item.dictionary.trim());
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|