@akanjs/devkit 2.3.9-rc.6 → 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/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.6",
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.6",
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",
@@ -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 orderedNextActions = uniqueBy(nextActions, (action) => action.command).sort((left, right) => {
56
- const leftRepair = left.command.startsWith("akan workflow explain") ? 0 : 1;
57
- const rightRepair = right.command.startsWith("akan workflow explain") ? 0 : 1;
58
- return leftRepair - rightRepair;
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 =
@@ -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 { moduleSourcePaths } from "./source";
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 = source.parseDiagnostics[0];
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 = capitalize(module);
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
+ });