@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
|
@@ -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
|
+
};
|