@akanjs/devkit 3.1.1-rc.15 → 3.1.1-rc.17

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.
Files changed (93) hide show
  1. package/agentAssets.test.ts +65 -0
  2. package/agentAssets.ts +166 -0
  3. package/agentsIndex.ts +4 -4
  4. package/akanContext.ts +44 -2
  5. package/applicationBuildRunner.test.ts +17 -0
  6. package/applicationBuildRunner.ts +3 -1
  7. package/biome.base.json +4 -1
  8. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  9. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  10. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  11. package/codeAgent/skills/akan-debug/SKILL.md +3 -3
  12. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  13. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  14. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  15. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  16. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  17. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  18. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  19. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  20. package/codeAgent/tools/AkanCli.ts +2 -1
  21. package/codeAgent/tools/AkanEditScope.ts +8 -3
  22. package/codeAgent/tools/AkanToolPack.ts +4 -10
  23. package/codeAgent/tools/AkanVerifier.ts +13 -31
  24. package/codeAgent/tools/akanCodePlugins.test.ts +12 -30
  25. package/commentDensityScanner.test.ts +121 -0
  26. package/commentDensityScanner.ts +101 -0
  27. package/componentSizeScanner.test.ts +36 -0
  28. package/componentSizeScanner.ts +52 -0
  29. package/componentWriteChainScanner.test.ts +137 -0
  30. package/componentWriteChainScanner.ts +104 -0
  31. package/conventionSet.test.ts +73 -0
  32. package/conventionSet.ts +67 -0
  33. package/deprecatedCommentScanner.test.ts +54 -0
  34. package/deprecatedCommentScanner.ts +30 -0
  35. package/endpointReach.test.ts +58 -0
  36. package/endpointReach.ts +80 -0
  37. package/executors.test.ts +0 -4
  38. package/index.ts +1 -0
  39. package/kindBranchScanner.test.ts +89 -0
  40. package/kindBranchScanner.ts +158 -0
  41. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  42. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  43. package/lint/no-oversized-list-limit.grit +73 -0
  44. package/maintainabilityBaseline.test.ts +280 -0
  45. package/maintainabilityBaseline.ts +220 -0
  46. package/maintainabilityContract.test.ts +846 -0
  47. package/maintainabilityContract.ts +404 -0
  48. package/maintainabilityExceptionScanner.test.ts +90 -0
  49. package/maintainabilityExceptionScanner.ts +85 -0
  50. package/maintainabilityLimits.ts +60 -0
  51. package/maintainabilityReport.test.ts +130 -0
  52. package/maintainabilityReport.ts +153 -0
  53. package/package.json +2 -2
  54. package/prompter.ts +7 -0
  55. package/qualityScanner.test.ts +75 -0
  56. package/qualityScanner.ts +17 -1
  57. package/serviceLoopCallScanner.test.ts +56 -0
  58. package/serviceLoopCallScanner.ts +122 -0
  59. package/sourceComments.test.ts +45 -0
  60. package/sourceComments.ts +70 -0
  61. package/storeAccessScanner.test.ts +84 -0
  62. package/storeAccessScanner.ts +71 -0
  63. package/storeSource.ts +159 -0
  64. package/storeWriteScanner.test.ts +292 -0
  65. package/storeWriteScanner.ts +50 -0
  66. package/testOnlyExportScanner.test.ts +83 -0
  67. package/testOnlyExportScanner.ts +124 -0
  68. package/typeChecker.ts +32 -0
  69. package/typecheck/projectTypecheck.test.ts +18 -0
  70. package/typecheck/projectTypecheck.ts +29 -5
  71. package/typecheck/typecheck.proc.ts +6 -0
  72. package/typecheck/typecheckFailure.ts +13 -0
  73. package/workflow/artifacts.ts +16 -2
  74. package/workflow/executor.ts +22 -7
  75. package/workflow/guardCatalog.test.ts +59 -0
  76. package/workflow/guardCatalog.ts +108 -0
  77. package/workflow/index.ts +4 -0
  78. package/workflow/interactionScaffold.ts +122 -0
  79. package/workflow/mutationScaffold.ts +519 -0
  80. package/workflow/mutationSource.test.ts +108 -0
  81. package/workflow/mutationSource.ts +219 -0
  82. package/workflow/plan.ts +54 -0
  83. package/workflow/render.ts +19 -4
  84. package/workflow/source.test.ts +185 -0
  85. package/workflow/source.ts +151 -24
  86. package/workflow/types.ts +18 -1
  87. package/workspaceLayout.ts +2 -0
  88. package/writePath.test.ts +109 -0
  89. package/writePath.ts +219 -0
  90. package/writePlacementScanner.test.ts +77 -0
  91. package/writePlacementScanner.ts +50 -0
  92. package/writingActionIndex.test.ts +99 -0
  93. package/writingActionIndex.ts +63 -0
@@ -0,0 +1,219 @@
1
+ import { lowerlize } from "akanjs/common";
2
+ import { bilingualLabelForField, moduleComponentName, titleize } from "./source";
3
+
4
+ export interface MutationCall {
5
+ ref: string;
6
+ method: string;
7
+ field: string | null;
8
+ args: string[];
9
+ }
10
+
11
+ export interface MutationActingUser {
12
+ argName: string;
13
+ param: string;
14
+ value: string;
15
+ serviceParam: string;
16
+ }
17
+
18
+ export interface MutationSourceSpec {
19
+ module: string;
20
+ mutation: string;
21
+ guards: readonly string[];
22
+ acting: MutationActingUser | null;
23
+ withCalls: readonly MutationCall[];
24
+ afterCalls: readonly MutationCall[];
25
+ }
26
+
27
+ export class MutationSource {
28
+ static readonly #irregularParticiples = new Map([
29
+ ["admit", "admitted"],
30
+ ["bind", "bound"],
31
+ ["build", "built"],
32
+ ["choose", "chosen"],
33
+ ["commit", "committed"],
34
+ ["control", "controlled"],
35
+ ["cut", "cut"],
36
+ ["find", "found"],
37
+ ["freeze", "frozen"],
38
+ ["get", "got"],
39
+ ["give", "given"],
40
+ ["hide", "hidden"],
41
+ ["hold", "held"],
42
+ ["keep", "kept"],
43
+ ["lead", "led"],
44
+ ["leave", "left"],
45
+ ["lose", "lost"],
46
+ ["make", "made"],
47
+ ["omit", "omitted"],
48
+ ["pay", "paid"],
49
+ ["permit", "permitted"],
50
+ ["put", "put"],
51
+ ["read", "read"],
52
+ ["run", "run"],
53
+ ["sell", "sold"],
54
+ ["send", "sent"],
55
+ ["set", "set"],
56
+ ["show", "shown"],
57
+ ["shut", "shut"],
58
+ ["split", "split"],
59
+ ["submit", "submitted"],
60
+ ["take", "taken"],
61
+ ["transfer", "transferred"],
62
+ ["win", "won"],
63
+ ["write", "written"],
64
+ ]);
65
+ readonly spec: MutationSourceSpec;
66
+ readonly modelClassName: string;
67
+ readonly model: string;
68
+ readonly idParam: string;
69
+ readonly chainMethod: string;
70
+
71
+ constructor(spec: MutationSourceSpec) {
72
+ this.spec = spec;
73
+ this.modelClassName = moduleComponentName(spec.module);
74
+ this.model = lowerlize(this.modelClassName);
75
+ this.idParam = `${this.model}Id`;
76
+ this.chainMethod = MutationSource.chainMethodOf(spec.mutation, this.modelClassName);
77
+ }
78
+
79
+ static chainMethodOf(mutation: string, modelClassName: string) {
80
+ const dropped = mutation.replace(modelClassName, "");
81
+ return dropped && dropped !== mutation ? lowerlize(dropped) : mutation;
82
+ }
83
+
84
+ static participleOf(verb: string) {
85
+ const irregular = MutationSource.#irregularParticiples.get(verb);
86
+ if (irregular) return irregular;
87
+ if (verb.endsWith("e")) return `${verb}d`;
88
+ if (/[^aeiou]y$/.test(verb)) return `${verb.slice(0, -1)}ied`;
89
+ if (/^[^aeiou]*[aeiou][^aeiouwxy]$/.test(verb)) return `${verb}${verb.at(-1)}ed`;
90
+ return `${verb}ed`;
91
+ }
92
+
93
+ get isPublic() {
94
+ return this.spec.guards.includes("Public");
95
+ }
96
+
97
+ get #verb() {
98
+ return this.chainMethod.replace(/[A-Z].*$/s, "");
99
+ }
100
+
101
+ get #noun() {
102
+ return titleize(this.model).toLowerCase();
103
+ }
104
+
105
+ get chainMethodBlock() {
106
+ return [
107
+ ` // any → ${MutationSource.participleOf(this.#verb)}.`,
108
+ ` ${this.chainMethod}() {`,
109
+ " return this;",
110
+ " }",
111
+ ].join("\n");
112
+ }
113
+
114
+ get serviceMethod() {
115
+ return this.chainMethod;
116
+ }
117
+
118
+ get serviceMethodBlock() {
119
+ const { acting } = this.spec;
120
+ const params = [`${this.idParam}: string`, ...(acting ? [`${acting.serviceParam}: string`] : [])];
121
+ const load = `const ${this.model} = await this.get${this.modelClassName}(${this.idParam});`;
122
+ const owned = acting
123
+ ? [
124
+ `// TODO: ${this.spec.guards.join(" and ")} admits any signed-in caller; refuse a ${acting.serviceParam} that may not ${this.#verb} this ${this.#noun}.`,
125
+ ]
126
+ : [];
127
+ const chained = `${this.model}.${this.chainMethod}().save()`;
128
+ const head = ` async ${this.serviceMethod}(${params.join(", ")}) {`;
129
+ if (!this.spec.withCalls.length && !this.spec.afterCalls.length)
130
+ return [head, ` ${load}`, ...owned.map((line) => ` ${line}`), ` return await ${chained};`, " }"].join(
131
+ "\n",
132
+ );
133
+ return [
134
+ head,
135
+ " return await this.transaction(async () => {",
136
+ ` ${load}`,
137
+ ...owned.map((line) => ` ${line}`),
138
+ ` await ${chained};`,
139
+ ...this.spec.withCalls.map((call) => ` await ${MutationSource.#callOf(call)};`),
140
+ ...this.spec.afterCalls.map((call) => ` await this.afterCommit(() => ${MutationSource.#callOf(call)});`),
141
+ ` return ${this.model};`,
142
+ " });",
143
+ " }",
144
+ ].join("\n");
145
+ }
146
+
147
+ get signalEntry() {
148
+ const options = this.isPublic
149
+ ? `{ guards: [${this.spec.guards.join(", ")}], rateLimit: { calls: 20 } }`
150
+ : `{ guards: [${this.spec.guards.join(", ")}] }`;
151
+ const { acting } = this.spec;
152
+ const execParams = [this.idParam, ...(acting ? [acting.param] : [])];
153
+ const serviceArgs = [this.idParam, ...(acting ? [acting.value] : [])];
154
+ return [
155
+ `${this.spec.mutation}: mutation(cnst.${this.modelClassName}, ${options})`,
156
+ ` .param("${this.idParam}", ID)`,
157
+ ...(acting ? [` .with(${acting.argName})`] : []),
158
+ ` .exec(async function (${execParams.join(", ")}) {`,
159
+ ` return await this.${this.model}Service.${this.serviceMethod}(${serviceArgs.join(", ")});`,
160
+ " }),",
161
+ ].join("\n");
162
+ }
163
+
164
+ get storeActionBlock() {
165
+ return [
166
+ ` async ${this.spec.mutation}(${this.idParam}: string) {`,
167
+ ` this.set${this.modelClassName}(await fetch.${this.spec.mutation}(${this.idParam}));`,
168
+ " }",
169
+ ].join("\n");
170
+ }
171
+
172
+ dictionaryEntryOf(modelKo?: string) {
173
+ const label = bilingualLabelForField(this.spec.mutation);
174
+ const modelLabel = { ...bilingualLabelForField(this.model), ...(modelKo ? { ko: modelKo } : {}) };
175
+ const pair = (en: string, ko: string) => `[${JSON.stringify(en)}, ${JSON.stringify(ko)}]`;
176
+ const idPair = pair(`${modelLabel.en} ID`, `${modelLabel.ko} ID`);
177
+ return [
178
+ `${this.spec.mutation}: fn(${pair(label.en, label.ko)})`,
179
+ ` .desc(${pair(`API to ${label.en.toLowerCase()}`, `${label.ko} API`)})`,
180
+ " .arg((t) => ({",
181
+ ` ${this.idParam}: t(${idPair}).desc(${idPair}),`,
182
+ " })),",
183
+ ].join("\n");
184
+ }
185
+
186
+ get testTitles() {
187
+ const guards = this.spec.guards.join(" and ");
188
+ const participle = MutationSource.participleOf(this.#verb);
189
+ return [
190
+ `${this.spec.mutation} leaves the ${this.#noun} ${participle} for a caller ${guards} admits`,
191
+ ...(this.spec.guards.every((guard) => guard === "Public")
192
+ ? []
193
+ : [`${this.spec.mutation} refuses a caller ${guards} turns away`]),
194
+ ...(this.spec.acting
195
+ ? [`${this.spec.mutation} refuses a signed-in caller who may not ${this.#verb} the ${this.#noun}`]
196
+ : []),
197
+ ];
198
+ }
199
+
200
+ testCaseLines(itName: string, indent: string) {
201
+ return this.testTitles.map((title) => `${indent}${itName}.todo(${JSON.stringify(title)});`);
202
+ }
203
+
204
+ get testFile() {
205
+ return [
206
+ 'import { describe, it } from "bun:test";',
207
+ "",
208
+ `describe(${JSON.stringify(`${this.modelClassName} Signal`)}, () => {`,
209
+ ...this.testCaseLines("it", " "),
210
+ "});",
211
+ "",
212
+ ].join("\n");
213
+ }
214
+
215
+ static #callOf(call: MutationCall) {
216
+ const target = call.field ? `this.${call.field}` : "this";
217
+ return `${target}.${call.method}(${call.args.join(", ")})`;
218
+ }
219
+ }
package/workflow/plan.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { capitalize } from "akanjs/common";
2
+ import type { Workspace } from "../commandDecorators";
2
3
  import { workflowPlanApproval } from "./artifacts";
4
+ import { resolveWorkflowSys } from "./executor";
5
+ import { InteractionScaffold } from "./interactionScaffold";
6
+ import { MutationScaffold } from "./mutationScaffold";
3
7
  import { coerceFieldDefault, moduleSourcePaths } from "./source";
4
8
  import type {
5
9
  WorkflowDiagnostic,
@@ -420,3 +424,53 @@ export const createWorkflowPlan = (spec: WorkflowSpec, rawInputs: Record<string,
420
424
  approval: workflowPlanApproval,
421
425
  };
422
426
  };
427
+
428
+ const planString = (inputs: Record<string, WorkflowInputValue>, name: string) =>
429
+ typeof inputs[name] === "string" ? inputs[name] : null;
430
+ const planStrings = (inputs: Record<string, WorkflowInputValue>, name: string) =>
431
+ Array.isArray(inputs[name]) ? inputs[name] : null;
432
+
433
+ export const inspectWorkflowPlan = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowPlan> => {
434
+ if (plan.workflow !== "add-mutation" && plan.workflow !== "create-interaction") return plan;
435
+ const app = planString(plan.inputs, "app");
436
+ if (!app) return plan;
437
+ const sys = await resolveWorkflowSys(workspace, app).catch(() => null);
438
+ if (!sys)
439
+ return {
440
+ ...plan,
441
+ diagnostics: [
442
+ ...plan.diagnostics,
443
+ {
444
+ severity: "error",
445
+ code: "workflow-target-missing",
446
+ input: "app",
447
+ message: `No app or library named ${app}.`,
448
+ },
449
+ ],
450
+ };
451
+ const inspected =
452
+ plan.workflow === "add-mutation"
453
+ ? await MutationScaffold.build(
454
+ sys,
455
+ {
456
+ module: planString(plan.inputs, "module"),
457
+ mutation: planString(plan.inputs, "mutation"),
458
+ guards: planStrings(plan.inputs, "guard"),
459
+ withCalls: planStrings(plan.inputs, "with"),
460
+ afterCalls: planStrings(plan.inputs, "after"),
461
+ },
462
+ { strict: false },
463
+ )
464
+ : { ...(await InteractionScaffold.build(sys, planString(plan.inputs, "interaction"))), recommendations: [] };
465
+ const failedInputs = new Set(
466
+ plan.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.input),
467
+ );
468
+ return {
469
+ ...plan,
470
+ diagnostics: [
471
+ ...plan.diagnostics,
472
+ ...inspected.diagnostics.filter((diagnostic) => !diagnostic.input || !failedInputs.has(diagnostic.input)),
473
+ ],
474
+ recommendations: [...plan.recommendations, ...inspected.recommendations],
475
+ };
476
+ };
@@ -1,3 +1,4 @@
1
+ import { camelToKebabCase } from "../commandDecorators/camelToKebabCase";
1
2
  import type {
2
3
  PrimitiveFormat,
3
4
  PrimitiveWriteReport,
@@ -33,7 +34,13 @@ export const renderWorkflowList = (specs: readonly WorkflowSpec[]) =>
33
34
  [
34
35
  "# Akan Workflows",
35
36
  "",
36
- ...specs.flatMap((spec) => [`- \`${spec.name}\`: ${spec.description}`, ` - When: ${spec.whenToUse}`]),
37
+ ...specs.flatMap((spec) => [
38
+ `- \`${spec.name}\`: ${spec.description}`,
39
+ ` - When: ${spec.whenToUse}`,
40
+ ...(spec.replacesRules?.length
41
+ ? [` - Fixes: ${spec.replacesRules.map((rule) => `\`${rule}\``).join(", ")}`]
42
+ : []),
43
+ ]),
37
44
  "",
38
45
  ].join("\n");
39
46
 
@@ -46,12 +53,20 @@ export const renderWorkflowExplain = (spec: WorkflowSpec) =>
46
53
  "## When To Use",
47
54
  spec.whenToUse,
48
55
  "",
56
+ ...(spec.replacesRules?.length
57
+ ? [
58
+ "## Replaces Rules",
59
+ "This path is the fix when lint or the gate reports:",
60
+ ...spec.replacesRules.map((rule) => `- \`${rule}\``),
61
+ "",
62
+ ]
63
+ : []),
49
64
  "## Inputs",
50
65
  ...Object.entries(spec.inputs).map(
51
66
  ([name, input]) =>
52
- `- \`${name}\`${input.required ? " (required)" : ""}: ${input.description}${
53
- input.allowedValues ? ` Allowed: ${input.allowedValues.join(", ")}.` : ""
54
- }`,
67
+ `- \`${name}\`${camelToKebabCase(name) === name ? "" : ` (\`--${camelToKebabCase(name)}\`)`}${
68
+ input.required ? " (required)" : ""
69
+ }: ${input.description}${input.allowedValues ? ` Allowed: ${input.allowedValues.join(", ")}.` : ""}`,
55
70
  ),
56
71
  "",
57
72
  "## Optional Surfaces",
@@ -1,9 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ appendTopLevelTestCases,
4
+ classMethodParameters,
5
+ ensureImportDeclaration,
6
+ ensureNamedImport,
3
7
  hasClassMethod,
8
+ hasDictionaryEndpointEntry,
4
9
  hasSignalFactoryEntry,
5
10
  hasSourceParseErrors,
6
11
  insertClassMethod,
12
+ insertDictionaryEndpointEntry,
13
+ insertDictionaryEnum,
7
14
  insertSignalFactoryEntry,
8
15
  inspectDictionaryStructure,
9
16
  } from "./source";
@@ -173,3 +180,181 @@ export class BannerEndpoint extends endpoint(srv.banner, ({ mutation }) => ({
173
180
  expect(next).toBe(content);
174
181
  });
175
182
  });
183
+
184
+ describe("import editing", () => {
185
+ const signal = `import { endpoint, internal, slice } from "akanjs/signal";
186
+
187
+ import * as srv from "../srv";
188
+ `;
189
+
190
+ test("adds a name in Biome's order and keeps a type modifier", () => {
191
+ const content = 'import { type Dayjs, dayjs } from "akanjs/base";\n';
192
+
193
+ expect(ensureNamedImport(content, "akanjs/base", "ID")).toBe(
194
+ 'import { type Dayjs, dayjs, ID } from "akanjs/base";\n',
195
+ );
196
+ expect(ensureNamedImport(signal, "akanjs/signal", "None")).toContain(
197
+ 'import { endpoint, internal, None, slice } from "akanjs/signal";',
198
+ );
199
+ });
200
+
201
+ test("never adds a value name to a type-only import", () => {
202
+ const next = ensureNamedImport('import type { Guard } from "akanjs/signal";\n', "akanjs/signal", "Public");
203
+
204
+ expect(next).toContain('import type { Guard } from "akanjs/signal";');
205
+ expect(next).toContain('import { Public } from "akanjs/signal";');
206
+ });
207
+
208
+ test("puts a new declaration in its chunk, in source order", () => {
209
+ const withPackages = ensureNamedImport(
210
+ ensureNamedImport(signal, "akanjs/base", "ID"),
211
+ "@libs/shared/srvkit",
212
+ "Admin",
213
+ );
214
+ const withRelative = ensureImportDeclaration(withPackages, "../cnst", "* as cnst");
215
+
216
+ expect(withRelative).toBe(`import { Admin } from "@libs/shared/srvkit";
217
+ import { ID } from "akanjs/base";
218
+ import { endpoint, internal, slice } from "akanjs/signal";
219
+
220
+ import * as cnst from "../cnst";
221
+ import * as srv from "../srv";
222
+ `);
223
+ expect(
224
+ ensureImportDeclaration('import { serve } from "akanjs/service";\n\nexport {};\n', "../srv", "type * as srv"),
225
+ ).toBe('import { serve } from "akanjs/service";\n\nimport type * as srv from "../srv";\n\nexport {};\n');
226
+ });
227
+ });
228
+
229
+ describe("dictionary endpoint entries", () => {
230
+ const dictionary = `export const dictionary = modelDictionary(["en", "ko"])
231
+ .slice<TaskSlice>((fn) => ({}))
232
+ .endpoint<TaskEndpoint>((fn) => ({}))
233
+ .error({})
234
+ .translate({});
235
+ `;
236
+
237
+ test("inserts an entry into the endpoint stage and finds it again", () => {
238
+ const next = insertDictionaryEndpointEntry(
239
+ dictionary,
240
+ "Task",
241
+ "archiveTask",
242
+ 'archiveTask: fn(["Archive", "보관"]),',
243
+ );
244
+
245
+ expect(next).toContain('.endpoint<TaskEndpoint>((fn) => ({\n archiveTask: fn(["Archive", "보관"]),\n }))');
246
+ expect(hasDictionaryEndpointEntry(next ?? "", "Task", "archiveTask")).toBe(true);
247
+ expect(hasDictionaryEndpointEntry(dictionary, "Task", "archiveTask")).toBe(false);
248
+ });
249
+
250
+ test("answers null when the dictionary has no endpoint stage for the model", () => {
251
+ expect(insertDictionaryEndpointEntry(dictionary, "Note", "pinNote", "pinNote: fn([]),")).toBeNull();
252
+ });
253
+ });
254
+
255
+ describe("insertDictionaryEnum", () => {
256
+ const dictionary = (tail: string) => `import { modelDictionary } from "akanjs/dictionary";
257
+
258
+ import type { Task } from "./task.constant";
259
+
260
+ // Stages: .model() field labels, .enum() value labels, .error() messages, .translate() UI messages.
261
+
262
+ export const dictionary = modelDictionary(["en", "ko"])
263
+ .of((t) => t(["Task", "할 일"]))
264
+ .model<Task>((t) => ({
265
+ title: t(["Title", "제목"]),
266
+ }))${tail};
267
+ `;
268
+
269
+ test("inserts before the chain's own .error() stage, not a comment that names it", () => {
270
+ const next = insertDictionaryEnum(
271
+ dictionary(`
272
+ .error({ notFound: ["Not found", "없음"] })
273
+ .translate({})`),
274
+ "TaskPriority",
275
+ "taskPriority",
276
+ ["low", "high"],
277
+ );
278
+ expect(next).not.toBeNull();
279
+ expect(hasSourceParseErrors(next ?? "", "task.dictionary.ts")).toBe(false);
280
+ expect(next?.split("\n").slice(-7)).toEqual([
281
+ ' .enum<TaskPriority>("taskPriority", (t) => ({',
282
+ ' low: t(["Low", "Low"]),',
283
+ ' high: t(["High", "High"]),',
284
+ " }))",
285
+ ' .error({ notFound: ["Not found", "없음"] })',
286
+ " .translate({});",
287
+ "",
288
+ ]);
289
+ expect(inspectDictionaryStructure(next ?? "", "Task").chainOrderValid).toBe(true);
290
+ });
291
+
292
+ test("appends to the end of a chain with no later stage", () => {
293
+ const next = insertDictionaryEnum(dictionary(""), "TaskPriority", "taskPriority", ["low"]);
294
+ expect(hasSourceParseErrors(next ?? "", "task.dictionary.ts")).toBe(false);
295
+ expect(next).toContain(
296
+ ' }))\n .enum<TaskPriority>("taskPriority", (t) => ({\n low: t(["Low", "Low"]),\n }));\n',
297
+ );
298
+ });
299
+
300
+ test("leaves an existing enum stage alone and refuses a file that does not parse", () => {
301
+ const withEnum = insertDictionaryEnum(dictionary(""), "TaskPriority", "taskPriority", ["low"]) ?? "";
302
+ expect(insertDictionaryEnum(withEnum, "TaskPriority", "taskPriority", ["low"])).toBe(withEnum);
303
+ expect(
304
+ insertDictionaryEnum("export const dictionary = modelDictionary([", "TaskPriority", "taskPriority", ["low"]),
305
+ ).toBeNull();
306
+ });
307
+ });
308
+
309
+ describe("classMethodParameters", () => {
310
+ test("reads names and which parameters a caller may leave out", () => {
311
+ const content = `export class NoteService {
312
+ async pushNote(taskId: string, note?: string, retries = 3) {}
313
+ }`;
314
+
315
+ expect(classMethodParameters(content, "NoteService", "pushNote")).toEqual([
316
+ { name: "taskId", optional: false },
317
+ { name: "note", optional: true },
318
+ { name: "retries", optional: true },
319
+ ]);
320
+ expect(classMethodParameters(content, "NoteService", "missing")).toBeNull();
321
+ });
322
+ });
323
+
324
+ describe("appendTopLevelTestCases", () => {
325
+ const render = (itName: string, indent: string) => [`${indent}${itName}.todo("archives");`];
326
+
327
+ test("appends inside the outermost describe with the runner name the file imports", () => {
328
+ const content = `import { describe, expect, test } from "bun:test";
329
+
330
+ describe("Task Signal", () => {
331
+ test("creates", () => {
332
+ expect(1).toBe(1);
333
+ });
334
+ });
335
+ `;
336
+
337
+ expect(
338
+ appendTopLevelTestCases(content, "Task Signal", render),
339
+ ).toBe(`import { describe, expect, test } from "bun:test";
340
+
341
+ describe("Task Signal", () => {
342
+ test("creates", () => {
343
+ expect(1).toBe(1);
344
+ });
345
+ test.todo("archives");
346
+ });
347
+ `);
348
+ });
349
+
350
+ test("adds a describe when the file has none", () => {
351
+ const next = appendTopLevelTestCases('import { expect } from "bun:test";\n', "Task Signal", render);
352
+
353
+ expect(next).toBe(`import { describe, expect, it } from "bun:test";
354
+
355
+ describe("Task Signal", () => {
356
+ it.todo("archives");
357
+ });
358
+ `);
359
+ });
360
+ });