@akanjs/devkit 2.3.9-rc.7 → 2.3.9-rc.9
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 +701 -0
- package/index.ts +1 -0
- package/package.json +2 -2
- package/workflow/artifacts.ts +166 -19
- package/workflow/executor.ts +211 -7
- package/workflow/index.ts +2 -0
- package/workflow/moduleIndex.test.ts +296 -0
- package/workflow/moduleIndex.ts +504 -0
- package/workflow/plan.ts +61 -7
- package/workflow/render.ts +49 -10
- package/workflow/rolloutGate.test.ts +109 -0
- package/workflow/rolloutGate.ts +141 -0
- package/workflow/source.test.ts +62 -0
- package/workflow/source.ts +463 -75
- package/workflow/types.ts +172 -2
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import type { AkanModuleContext } from "../akanContext";
|
|
5
|
+
import type { Workspace } from "../commandDecorators";
|
|
6
|
+
import { moduleComponentName, moduleSourcePaths } from "./source";
|
|
7
|
+
import type {
|
|
8
|
+
AkanConstantIndex,
|
|
9
|
+
AkanDictionaryIndex,
|
|
10
|
+
AkanFieldOutline,
|
|
11
|
+
AkanFieldPresence,
|
|
12
|
+
AkanIndexedFile,
|
|
13
|
+
AkanIndexedFileKind,
|
|
14
|
+
AkanModuleContextIndex,
|
|
15
|
+
AkanProjectionOutline,
|
|
16
|
+
AkanSourceSpan,
|
|
17
|
+
WorkflowDiagnostic,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
export interface BuildAkanModuleContextIndexOptions {
|
|
21
|
+
field?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const indexFileKinds = [
|
|
25
|
+
"abstract",
|
|
26
|
+
"constant",
|
|
27
|
+
"dictionary",
|
|
28
|
+
"service",
|
|
29
|
+
"signal",
|
|
30
|
+
"store",
|
|
31
|
+
"template",
|
|
32
|
+
"unit",
|
|
33
|
+
"util",
|
|
34
|
+
"view",
|
|
35
|
+
"zone",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const sourceText = async (filePath: string) => {
|
|
39
|
+
try {
|
|
40
|
+
return await Bun.file(filePath).text();
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const fileExists = async (filePath: string) => {
|
|
47
|
+
try {
|
|
48
|
+
await access(filePath);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const sourceFileFor = (filePath: string, content: string) =>
|
|
56
|
+
ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
57
|
+
|
|
58
|
+
const parseDiagnosticsFor = (source: ts.SourceFile, filePath: string): WorkflowDiagnostic[] => {
|
|
59
|
+
const parseDiagnostics =
|
|
60
|
+
(source as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] }).parseDiagnostics ?? [];
|
|
61
|
+
return parseDiagnostics.map((diagnostic) => ({
|
|
62
|
+
severity: "error",
|
|
63
|
+
code: "module-index-typescript-parse-error",
|
|
64
|
+
message: `TypeScript parse diagnostic TS${diagnostic.code} in ${filePath}: ${ts.flattenDiagnosticMessageText(
|
|
65
|
+
diagnostic.messageText,
|
|
66
|
+
"\n",
|
|
67
|
+
)}`,
|
|
68
|
+
context: { target: filePath, paths: [filePath] },
|
|
69
|
+
}));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const spanFor = (source: ts.SourceFile, file: string, node: ts.Node): AkanSourceSpan => {
|
|
73
|
+
const startOffset = node.getStart(source);
|
|
74
|
+
const endOffset = node.getEnd();
|
|
75
|
+
const start = source.getLineAndCharacterOfPosition(startOffset);
|
|
76
|
+
const end = source.getLineAndCharacterOfPosition(endOffset);
|
|
77
|
+
return {
|
|
78
|
+
file,
|
|
79
|
+
startLine: start.line + 1,
|
|
80
|
+
endLine: end.line + 1,
|
|
81
|
+
startOffset,
|
|
82
|
+
endOffset,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const nodeName = (node: ts.PropertyName | ts.BindingName | undefined) => {
|
|
87
|
+
if (!node) return null;
|
|
88
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return node.text;
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const propertyName = (node: ts.ObjectLiteralElementLike) =>
|
|
93
|
+
ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node) || ts.isMethodDeclaration(node)
|
|
94
|
+
? nodeName(node.name)
|
|
95
|
+
: null;
|
|
96
|
+
|
|
97
|
+
const expressionName = (expression: ts.Expression): string | null => {
|
|
98
|
+
if (ts.isIdentifier(expression)) return expression.text;
|
|
99
|
+
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
|
100
|
+
if (ts.isCallExpression(expression)) return expressionName(expression.expression);
|
|
101
|
+
if (ts.isAsExpression(expression)) return expressionName(expression.expression);
|
|
102
|
+
return null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const expressionSummary = (expression: ts.Expression): string => {
|
|
106
|
+
if (ts.isAsExpression(expression)) return expressionSummary(expression.expression);
|
|
107
|
+
if (ts.isIdentifier(expression) || ts.isPropertyAccessExpression(expression))
|
|
108
|
+
return expressionName(expression) ?? "expression";
|
|
109
|
+
if (ts.isArrayLiteralExpression(expression)) return "array-literal";
|
|
110
|
+
if (ts.isObjectLiteralExpression(expression)) return "object-literal";
|
|
111
|
+
if (ts.isStringLiteralLike(expression)) return "string-literal";
|
|
112
|
+
if (ts.isNumericLiteral(expression)) return "numeric-literal";
|
|
113
|
+
if (expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) {
|
|
114
|
+
return "boolean-literal";
|
|
115
|
+
}
|
|
116
|
+
if (ts.isCallExpression(expression)) {
|
|
117
|
+
const callee = expressionName(expression.expression);
|
|
118
|
+
return callee ? `${callee}(...)` : "call-expression";
|
|
119
|
+
}
|
|
120
|
+
return ts.SyntaxKind[expression.kind] ?? "expression";
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const typeSummaryForInitializer = (initializer: ts.Expression | undefined) => {
|
|
124
|
+
if (!initializer) return undefined;
|
|
125
|
+
const expression = ts.isAsExpression(initializer) ? initializer.expression : initializer;
|
|
126
|
+
if (!ts.isCallExpression(expression)) return expressionSummary(expression);
|
|
127
|
+
const callee = expressionName(expression.expression);
|
|
128
|
+
const firstArg = expression.arguments[0];
|
|
129
|
+
const argSummary = firstArg ? expressionSummary(firstArg) : "";
|
|
130
|
+
return [callee, argSummary ? `(${argSummary})` : ""].filter(Boolean).join("");
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const fieldsFromObject = (
|
|
134
|
+
source: ts.SourceFile,
|
|
135
|
+
file: string,
|
|
136
|
+
objectLiteral: ts.ObjectLiteralExpression,
|
|
137
|
+
kind: AkanFieldOutline["kind"],
|
|
138
|
+
) =>
|
|
139
|
+
objectLiteral.properties
|
|
140
|
+
.map((property, index): AkanFieldOutline | null => {
|
|
141
|
+
const name = propertyName(property);
|
|
142
|
+
if (!name) return null;
|
|
143
|
+
const initializer = ts.isPropertyAssignment(property) ? property.initializer : undefined;
|
|
144
|
+
return {
|
|
145
|
+
name,
|
|
146
|
+
kind,
|
|
147
|
+
order: index,
|
|
148
|
+
...(initializer ? { typeSummary: typeSummaryForInitializer(initializer) } : {}),
|
|
149
|
+
sourceSpan: spanFor(source, file, property),
|
|
150
|
+
};
|
|
151
|
+
})
|
|
152
|
+
.filter((field): field is AkanFieldOutline => field !== null);
|
|
153
|
+
|
|
154
|
+
const firstObjectReturnedByArrow = (node: ts.Node): ts.ObjectLiteralExpression | null => {
|
|
155
|
+
if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return null;
|
|
156
|
+
if (ts.isObjectLiteralExpression(node.body)) return node.body;
|
|
157
|
+
if (ts.isParenthesizedExpression(node.body) && ts.isObjectLiteralExpression(node.body.expression)) {
|
|
158
|
+
return node.body.expression;
|
|
159
|
+
}
|
|
160
|
+
if (!ts.isBlock(node.body)) return null;
|
|
161
|
+
for (const statement of node.body.statements) {
|
|
162
|
+
if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(statement.expression)) {
|
|
163
|
+
return statement.expression;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const isViaCall = (expression: ts.Expression) =>
|
|
170
|
+
ts.isCallExpression(expression) && expressionName(expression.expression) === "via";
|
|
171
|
+
|
|
172
|
+
const heritageCall = (node: ts.ClassDeclaration) => {
|
|
173
|
+
const heritage = node.heritageClauses?.flatMap((clause) => [...clause.types]) ?? [];
|
|
174
|
+
const expression = heritage.find((clause) => isViaCall(clause.expression))?.expression;
|
|
175
|
+
return expression && ts.isCallExpression(expression) ? expression : null;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
interface ParsedConstantIndex {
|
|
179
|
+
index: AkanConstantIndex;
|
|
180
|
+
diagnostics: WorkflowDiagnostic[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ParsedDictionaryIndex {
|
|
184
|
+
index?: AkanDictionaryIndex;
|
|
185
|
+
diagnostics: WorkflowDiagnostic[];
|
|
186
|
+
modelFound: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const parseConstantIndex = (filePath: string, content: string, moduleClassName: string): ParsedConstantIndex => {
|
|
190
|
+
const source = sourceFileFor(filePath, content);
|
|
191
|
+
const diagnostics = parseDiagnosticsFor(source, filePath);
|
|
192
|
+
const inputClassName = `${moduleClassName}Input`;
|
|
193
|
+
let inputClass: ts.ClassDeclaration | null = null;
|
|
194
|
+
let inputViaFound = false;
|
|
195
|
+
let inputBuilderFound = false;
|
|
196
|
+
let inputBuilderObjectFound = false;
|
|
197
|
+
let builderName: string | null = null;
|
|
198
|
+
let fields: AkanFieldOutline[] = [];
|
|
199
|
+
let lightProjection: AkanProjectionOutline | undefined;
|
|
200
|
+
|
|
201
|
+
const visit = (node: ts.Node) => {
|
|
202
|
+
if (!ts.isClassDeclaration(node) || !node.name) {
|
|
203
|
+
ts.forEachChild(node, visit);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const className = node.name.text;
|
|
207
|
+
const viaCall = heritageCall(node);
|
|
208
|
+
if (className === inputClassName) {
|
|
209
|
+
inputClass = node;
|
|
210
|
+
if (!viaCall) return;
|
|
211
|
+
inputViaFound = true;
|
|
212
|
+
const callback = viaCall.arguments.find((arg) => ts.isArrowFunction(arg) || ts.isFunctionExpression(arg));
|
|
213
|
+
if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
|
|
214
|
+
inputBuilderFound = true;
|
|
215
|
+
builderName = nodeName(callback.parameters[0]?.name) ?? null;
|
|
216
|
+
const objectLiteral = firstObjectReturnedByArrow(callback);
|
|
217
|
+
if (objectLiteral) {
|
|
218
|
+
inputBuilderObjectFound = true;
|
|
219
|
+
fields = fieldsFromObject(source, filePath, objectLiteral, "constant");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!viaCall) return;
|
|
224
|
+
if (className === `Light${moduleClassName}`) {
|
|
225
|
+
const projectionArg = viaCall.arguments.find((arg) => {
|
|
226
|
+
const expression = ts.isAsExpression(arg) ? arg.expression : arg;
|
|
227
|
+
return ts.isArrayLiteralExpression(expression);
|
|
228
|
+
});
|
|
229
|
+
const arrayLiteral = projectionArg
|
|
230
|
+
? ts.isAsExpression(projectionArg)
|
|
231
|
+
? projectionArg.expression
|
|
232
|
+
: projectionArg
|
|
233
|
+
: null;
|
|
234
|
+
if (arrayLiteral && ts.isArrayLiteralExpression(arrayLiteral)) {
|
|
235
|
+
lightProjection = {
|
|
236
|
+
className,
|
|
237
|
+
sourceSpan: spanFor(source, filePath, arrayLiteral),
|
|
238
|
+
fields: arrayLiteral.elements
|
|
239
|
+
.map((element, order): AkanFieldOutline | null =>
|
|
240
|
+
ts.isStringLiteralLike(element)
|
|
241
|
+
? {
|
|
242
|
+
name: element.text,
|
|
243
|
+
kind: "lightProjection",
|
|
244
|
+
order,
|
|
245
|
+
typeSummary: "string-literal",
|
|
246
|
+
sourceSpan: spanFor(source, filePath, element),
|
|
247
|
+
}
|
|
248
|
+
: null,
|
|
249
|
+
)
|
|
250
|
+
.filter((field): field is AkanFieldOutline => field !== null),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
ts.forEachChild(source, visit);
|
|
256
|
+
|
|
257
|
+
if (!inputClass) {
|
|
258
|
+
diagnostics.push({
|
|
259
|
+
severity: "error",
|
|
260
|
+
code: "module-index-constant-input-missing",
|
|
261
|
+
message: `Constant input class ${inputClassName} was not found in ${filePath}.`,
|
|
262
|
+
context: { target: inputClassName, paths: [filePath] },
|
|
263
|
+
});
|
|
264
|
+
} else if (!inputViaFound) {
|
|
265
|
+
diagnostics.push({
|
|
266
|
+
severity: "error",
|
|
267
|
+
code: "module-index-constant-via-missing",
|
|
268
|
+
message: `Constant input class ${inputClassName} does not extend via(...) in ${filePath}.`,
|
|
269
|
+
context: { target: inputClassName, paths: [filePath] },
|
|
270
|
+
});
|
|
271
|
+
} else if (!inputBuilderFound) {
|
|
272
|
+
diagnostics.push({
|
|
273
|
+
severity: "error",
|
|
274
|
+
code: "module-index-constant-builder-missing",
|
|
275
|
+
message: `Constant input class ${inputClassName} does not provide a via(...) builder callback in ${filePath}.`,
|
|
276
|
+
context: { target: inputClassName, paths: [filePath] },
|
|
277
|
+
});
|
|
278
|
+
} else if (!inputBuilderObjectFound) {
|
|
279
|
+
diagnostics.push({
|
|
280
|
+
severity: "error",
|
|
281
|
+
code: "module-index-constant-builder-object-missing",
|
|
282
|
+
message: `Constant input class ${inputClassName} builder does not return an object literal in ${filePath}.`,
|
|
283
|
+
context: { target: inputClassName, paths: [filePath] },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
index: {
|
|
289
|
+
path: filePath,
|
|
290
|
+
inputClassName,
|
|
291
|
+
builderName,
|
|
292
|
+
fields,
|
|
293
|
+
...(lightProjection ? { lightProjection } : {}),
|
|
294
|
+
...(inputClass ? { sourceSpan: spanFor(source, filePath, inputClass) } : {}),
|
|
295
|
+
},
|
|
296
|
+
diagnostics,
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const callExpressionName = (node: ts.CallExpression) =>
|
|
301
|
+
ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : expressionName(node.expression);
|
|
302
|
+
|
|
303
|
+
const parseDictionaryIndex = (filePath: string, content: string, moduleClassName: string): ParsedDictionaryIndex => {
|
|
304
|
+
const source = sourceFileFor(filePath, content);
|
|
305
|
+
const diagnostics = parseDiagnosticsFor(source, filePath);
|
|
306
|
+
let index: AkanDictionaryIndex | undefined;
|
|
307
|
+
let modelFound = false;
|
|
308
|
+
|
|
309
|
+
const visit = (node: ts.Node) => {
|
|
310
|
+
if (modelFound || !ts.isCallExpression(node)) {
|
|
311
|
+
ts.forEachChild(node, visit);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (callExpressionName(node) !== "model") {
|
|
315
|
+
ts.forEachChild(node, visit);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const typeArgument = node.typeArguments?.[0];
|
|
319
|
+
if (!typeArgument || typeArgument.getText(source) !== moduleClassName) {
|
|
320
|
+
ts.forEachChild(node, visit);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
modelFound = true;
|
|
324
|
+
const callback = node.arguments.find((arg) => ts.isArrowFunction(arg) || ts.isFunctionExpression(arg));
|
|
325
|
+
if (!callback || (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))) {
|
|
326
|
+
diagnostics.push({
|
|
327
|
+
severity: "error",
|
|
328
|
+
code: "module-index-dictionary-builder-missing",
|
|
329
|
+
message: `Dictionary .model<${moduleClassName}> branch does not provide a builder callback in ${filePath}.`,
|
|
330
|
+
context: { target: moduleClassName, paths: [filePath] },
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const objectLiteral = firstObjectReturnedByArrow(callback);
|
|
335
|
+
if (!objectLiteral) {
|
|
336
|
+
diagnostics.push({
|
|
337
|
+
severity: "error",
|
|
338
|
+
code: "module-index-dictionary-builder-object-missing",
|
|
339
|
+
message: `Dictionary .model<${moduleClassName}> builder does not return an object literal in ${filePath}.`,
|
|
340
|
+
context: { target: moduleClassName, paths: [filePath] },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
index = {
|
|
344
|
+
path: filePath,
|
|
345
|
+
modelClassName: moduleClassName,
|
|
346
|
+
translatorName: nodeName(callback.parameters[0]?.name),
|
|
347
|
+
fields: objectLiteral ? fieldsFromObject(source, filePath, objectLiteral, "dictionary") : [],
|
|
348
|
+
sourceSpan: spanFor(source, filePath, node),
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
ts.forEachChild(source, visit);
|
|
352
|
+
return { index, diagnostics, modelFound };
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const expectedFilesFor = (module: AkanModuleContext): AkanIndexedFile[] => {
|
|
356
|
+
const paths = moduleSourcePaths(module.name);
|
|
357
|
+
return indexFileKinds.map((kind) => {
|
|
358
|
+
const expectedModulePath = paths[kind];
|
|
359
|
+
const expectedFilename = path.basename(expectedModulePath);
|
|
360
|
+
const actualFilename =
|
|
361
|
+
module.files.find((file) => file === expectedFilename) ??
|
|
362
|
+
module.files.find((file) => file.toLowerCase() === expectedFilename.toLowerCase());
|
|
363
|
+
const present = actualFilename !== undefined;
|
|
364
|
+
const casing = !present ? "missing" : actualFilename === expectedFilename ? "match" : "mismatch";
|
|
365
|
+
return {
|
|
366
|
+
kind: kind as AkanIndexedFileKind,
|
|
367
|
+
path: `${module.path}/${actualFilename ?? expectedFilename}`,
|
|
368
|
+
expectedPath: `${module.path}/${expectedFilename}`,
|
|
369
|
+
filename: actualFilename ?? expectedFilename,
|
|
370
|
+
expectedFilename,
|
|
371
|
+
present,
|
|
372
|
+
casing,
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const diagnosticsForFiles = (files: AkanIndexedFile[]): WorkflowDiagnostic[] =>
|
|
378
|
+
files.flatMap((file) => {
|
|
379
|
+
if (file.kind !== "constant" && file.kind !== "dictionary") return [];
|
|
380
|
+
if (file.casing === "match") return [];
|
|
381
|
+
return [
|
|
382
|
+
{
|
|
383
|
+
severity: "error",
|
|
384
|
+
code: file.casing === "missing" ? "module-index-file-missing" : "module-index-file-casing-mismatch",
|
|
385
|
+
message:
|
|
386
|
+
file.casing === "missing"
|
|
387
|
+
? `Expected ${file.kind} file is missing: ${file.expectedPath}.`
|
|
388
|
+
: `Expected ${file.expectedPath}, but found ${file.path}.`,
|
|
389
|
+
context: { target: file.expectedPath, paths: [file.path, file.expectedPath] },
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const fieldPresence = (
|
|
395
|
+
requestedField: string | undefined,
|
|
396
|
+
constantFields: AkanFieldOutline[],
|
|
397
|
+
dictionaryFields: AkanFieldOutline[],
|
|
398
|
+
lightFields: AkanFieldOutline[],
|
|
399
|
+
) => {
|
|
400
|
+
const names = new Set([
|
|
401
|
+
...constantFields.map((field) => field.name),
|
|
402
|
+
...dictionaryFields.map((field) => field.name),
|
|
403
|
+
...lightFields.map((field) => field.name),
|
|
404
|
+
...(requestedField ? [requestedField] : []),
|
|
405
|
+
]);
|
|
406
|
+
return [...names].sort().map(
|
|
407
|
+
(name): AkanFieldPresence => ({
|
|
408
|
+
name,
|
|
409
|
+
requested: requestedField === name,
|
|
410
|
+
constant: constantFields.some((field) => field.name === name),
|
|
411
|
+
dictionary: dictionaryFields.some((field) => field.name === name),
|
|
412
|
+
lightProjection: lightFields.some((field) => field.name === name),
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const partialPresenceDiagnostics = (presence: AkanFieldPresence[], module: AkanModuleContext): WorkflowDiagnostic[] =>
|
|
418
|
+
presence
|
|
419
|
+
.filter(
|
|
420
|
+
(field) =>
|
|
421
|
+
field.constant !== field.dictionary || (field.lightProjection && (!field.constant || !field.dictionary)),
|
|
422
|
+
)
|
|
423
|
+
.map((field) => ({
|
|
424
|
+
severity: "warning",
|
|
425
|
+
code: "module-index-field-presence-partial",
|
|
426
|
+
message: `${module.sysName}:${module.name}.${field.name} presence differs across constant, dictionary, and lightProjection.`,
|
|
427
|
+
context: {
|
|
428
|
+
target: `${module.sysName}:${module.name}.${field.name}`,
|
|
429
|
+
paths: [`${module.path}/${module.name}.constant.ts`, `${module.path}/${module.name}.dictionary.ts`],
|
|
430
|
+
},
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
export const buildAkanModuleContextIndex = async (
|
|
434
|
+
workspace: Workspace,
|
|
435
|
+
module: AkanModuleContext,
|
|
436
|
+
options: BuildAkanModuleContextIndexOptions = {},
|
|
437
|
+
): Promise<AkanModuleContextIndex> => {
|
|
438
|
+
const files = expectedFilesFor(module);
|
|
439
|
+
const diagnostics = diagnosticsForFiles(files);
|
|
440
|
+
const moduleClassName = moduleComponentName(module.name);
|
|
441
|
+
const constantFile = files.find((file) => file.kind === "constant");
|
|
442
|
+
const dictionaryFile = files.find((file) => file.kind === "dictionary");
|
|
443
|
+
const constantContent =
|
|
444
|
+
constantFile?.present && constantFile.casing === "match"
|
|
445
|
+
? await sourceText(path.join(workspace.workspaceRoot, constantFile.path))
|
|
446
|
+
: null;
|
|
447
|
+
const dictionaryContent =
|
|
448
|
+
dictionaryFile?.present && dictionaryFile.casing === "match"
|
|
449
|
+
? await sourceText(path.join(workspace.workspaceRoot, dictionaryFile.path))
|
|
450
|
+
: null;
|
|
451
|
+
const parsedConstant =
|
|
452
|
+
constantFile && constantContent
|
|
453
|
+
? parseConstantIndex(constantFile.path, constantContent, moduleClassName)
|
|
454
|
+
: undefined;
|
|
455
|
+
const constant = parsedConstant?.index;
|
|
456
|
+
const parsedDictionary =
|
|
457
|
+
dictionaryFile && dictionaryContent
|
|
458
|
+
? parseDictionaryIndex(dictionaryFile.path, dictionaryContent, moduleClassName)
|
|
459
|
+
: undefined;
|
|
460
|
+
const dictionary = parsedDictionary?.index;
|
|
461
|
+
const presence = fieldPresence(
|
|
462
|
+
options.field,
|
|
463
|
+
constant?.fields ?? [],
|
|
464
|
+
dictionary?.fields ?? [],
|
|
465
|
+
constant?.lightProjection?.fields ?? [],
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
if (
|
|
469
|
+
constantFile?.present &&
|
|
470
|
+
constantFile.casing === "match" &&
|
|
471
|
+
!(await fileExists(path.join(workspace.workspaceRoot, constantFile.path)))
|
|
472
|
+
) {
|
|
473
|
+
diagnostics.push({
|
|
474
|
+
severity: "error",
|
|
475
|
+
code: "module-index-file-unreadable",
|
|
476
|
+
message: `Expected constant file could not be read: ${constantFile.path}.`,
|
|
477
|
+
context: { paths: [constantFile.path] },
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (parsedConstant) diagnostics.push(...parsedConstant.diagnostics);
|
|
481
|
+
if (parsedDictionary) diagnostics.push(...parsedDictionary.diagnostics);
|
|
482
|
+
if (dictionaryFile?.present && dictionaryFile.casing === "match" && !parsedDictionary?.modelFound) {
|
|
483
|
+
diagnostics.push({
|
|
484
|
+
severity: "error",
|
|
485
|
+
code: "module-index-dictionary-model-missing",
|
|
486
|
+
message: `Dictionary .model<${moduleClassName}> branch was not found in ${dictionaryFile.path}.`,
|
|
487
|
+
context: { paths: [dictionaryFile.path] },
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
diagnostics.push(...partialPresenceDiagnostics(presence, module));
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
schemaVersion: 1,
|
|
495
|
+
app: module.sysName,
|
|
496
|
+
module: module.name,
|
|
497
|
+
moduleClassName,
|
|
498
|
+
files,
|
|
499
|
+
fieldPresence: presence,
|
|
500
|
+
...(constant ? { constant } : {}),
|
|
501
|
+
...(dictionary ? { dictionary } : {}),
|
|
502
|
+
diagnostics,
|
|
503
|
+
};
|
|
504
|
+
};
|
package/workflow/plan.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { capitalize } from "akanjs/common";
|
|
2
|
+
import { workflowPlanApproval } from "./artifacts";
|
|
2
3
|
import { coerceFieldDefault, moduleSourcePaths } from "./source";
|
|
3
4
|
import type {
|
|
4
5
|
WorkflowDiagnostic,
|
|
@@ -91,11 +92,59 @@ const createAddFieldDefaultRecommendations = (inputs: Record<string, WorkflowInp
|
|
|
91
92
|
kind: "manual-action",
|
|
92
93
|
confidence: "high",
|
|
93
94
|
message: `Default for ${field} will be normalized to ${coercion.normalizedType} literal ${coercion.expression}.`,
|
|
94
|
-
action:
|
|
95
|
+
action:
|
|
96
|
+
"Review the normalized default in the apply report. To leave the field without a default, omit the default input.",
|
|
95
97
|
},
|
|
96
98
|
];
|
|
97
99
|
};
|
|
98
100
|
|
|
101
|
+
const moneyLikeFieldPattern = /(budget|price|amount|cost|rate|fee|salary|balance)/i;
|
|
102
|
+
const wholeNumberFieldPattern = /(count|quantity|total|number|index|rank|order|age)/i;
|
|
103
|
+
|
|
104
|
+
const createAddFieldInputRecommendations = (inputs: Record<string, WorkflowInputValue>): WorkflowRecommendation[] => {
|
|
105
|
+
const field = typeof inputs.field === "string" ? inputs.field : "<field>";
|
|
106
|
+
const typeName = typeof inputs.type === "string" ? inputs.type : null;
|
|
107
|
+
const recommendations: WorkflowRecommendation[] = [];
|
|
108
|
+
if (!typeName) return recommendations;
|
|
109
|
+
if (typeName.toLowerCase() === "number" || typeName.toLowerCase() === "numeric") {
|
|
110
|
+
const recommendedType = moneyLikeFieldPattern.test(field)
|
|
111
|
+
? "Float"
|
|
112
|
+
: wholeNumberFieldPattern.test(field)
|
|
113
|
+
? "Int"
|
|
114
|
+
: null;
|
|
115
|
+
recommendations.push({
|
|
116
|
+
code: "add-field-type-choice",
|
|
117
|
+
kind: "input-guidance",
|
|
118
|
+
confidence: recommendedType ? "high" : "medium",
|
|
119
|
+
message: recommendedType
|
|
120
|
+
? `Use ${recommendedType} for ${field}; Float is recommended for money-like decimal values and Int for whole-number values.`
|
|
121
|
+
: `Choose Int for whole-number ${field} values or Float for decimal ${field} values.`,
|
|
122
|
+
action: recommendedType
|
|
123
|
+
? `Re-run plan_workflow with type="${recommendedType}".`
|
|
124
|
+
: 'Re-run plan_workflow with type="Int" or type="Float".',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (typeName.toLowerCase() === "enum" && !Array.isArray(inputs.values)) {
|
|
128
|
+
recommendations.push({
|
|
129
|
+
code: "add-field-enum-values",
|
|
130
|
+
kind: "input-guidance",
|
|
131
|
+
confidence: "high",
|
|
132
|
+
message: `Enum field ${field} needs values before apply can choose valid dictionary and default behavior.`,
|
|
133
|
+
action: 'Pass values as an array or comma-separated string, for example values=["draft","done"].',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (inputs.default === undefined) {
|
|
137
|
+
recommendations.push({
|
|
138
|
+
code: "add-field-default-optional",
|
|
139
|
+
kind: "input-guidance",
|
|
140
|
+
confidence: "medium",
|
|
141
|
+
message: `No default will be written for ${field} because default is omitted.`,
|
|
142
|
+
action: "Keep default omitted when the field should have no default value.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return recommendations;
|
|
146
|
+
};
|
|
147
|
+
|
|
99
148
|
const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue>): WorkflowRecommendation[] => {
|
|
100
149
|
const app = typeof inputs.app === "string" ? inputs.app : "<app-or-lib>";
|
|
101
150
|
const module = typeof inputs.module === "string" ? inputs.module : "<module>";
|
|
@@ -163,9 +212,11 @@ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue
|
|
|
163
212
|
code: "add-field-light-projection-choice",
|
|
164
213
|
kind: "manual-action" as const,
|
|
165
214
|
target: paths.constant,
|
|
166
|
-
action: `Pass includeInLight=true when ${field}
|
|
215
|
+
action: `Pass includeInLight=true when users should see ${field} in list/card data. Without it, the field can exist on ${capitalize(
|
|
216
|
+
module,
|
|
217
|
+
)}Input but stay absent from Light${capitalize(module)} projections.`,
|
|
167
218
|
confidence: "medium" as const,
|
|
168
|
-
message:
|
|
219
|
+
message: `${module}.${field} is not selected for list/card projection data.`,
|
|
169
220
|
},
|
|
170
221
|
]
|
|
171
222
|
: []),
|
|
@@ -187,7 +238,8 @@ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue
|
|
|
187
238
|
kind: "manual-action" as const,
|
|
188
239
|
target: paths.template,
|
|
189
240
|
confidence: "medium" as const,
|
|
190
|
-
message: `Template
|
|
241
|
+
message: `Template form auto-edit is skipped for ${module}.${field} because surfaces does not include "template".`,
|
|
242
|
+
action: `Pass surfaces=["template"] when users should enter ${field} in the Template form.`,
|
|
191
243
|
},
|
|
192
244
|
]
|
|
193
245
|
: []),
|
|
@@ -195,13 +247,14 @@ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue
|
|
|
195
247
|
code: "add-field-ui-manual-review",
|
|
196
248
|
kind: "manual-action",
|
|
197
249
|
target: paths.template,
|
|
198
|
-
action: `
|
|
250
|
+
action: `After apply, verify what users can see: Template form auto-edit only runs when a generated ${module}Form hook and Layout.Template field list are present. Unit/View cards are not auto-edited, so ${field} may be stored on ${capitalize(
|
|
199
251
|
module,
|
|
200
|
-
)}
|
|
252
|
+
)} but not shown in list/card UI until you place it in the local card layout.`,
|
|
201
253
|
confidence: "medium",
|
|
202
|
-
message:
|
|
254
|
+
message: `${app}:${module}.${field} may need UI review for user-visible Template, Unit, and View behavior.`,
|
|
203
255
|
},
|
|
204
256
|
...createAddFieldDefaultRecommendations(inputs),
|
|
257
|
+
...createAddFieldInputRecommendations(inputs),
|
|
205
258
|
];
|
|
206
259
|
};
|
|
207
260
|
|
|
@@ -371,5 +424,6 @@ export const createWorkflowPlan = (spec: WorkflowSpec, rawInputs: Record<string,
|
|
|
371
424
|
diagnostics,
|
|
372
425
|
recommendations: createWorkflowPlanRecommendations(spec, inputs),
|
|
373
426
|
requiresApproval: true,
|
|
427
|
+
approval: workflowPlanApproval,
|
|
374
428
|
};
|
|
375
429
|
};
|