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

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 (63) hide show
  1. package/applicationBuildRunner.test.ts +17 -0
  2. package/applicationBuildRunner.ts +2 -1
  3. package/biome.base.json +4 -1
  4. package/commentDensityScanner.test.ts +121 -0
  5. package/commentDensityScanner.ts +101 -0
  6. package/componentSizeScanner.test.ts +36 -0
  7. package/componentSizeScanner.ts +52 -0
  8. package/componentWriteChainScanner.test.ts +137 -0
  9. package/componentWriteChainScanner.ts +104 -0
  10. package/deprecatedCommentScanner.test.ts +54 -0
  11. package/deprecatedCommentScanner.ts +30 -0
  12. package/endpointReach.test.ts +58 -0
  13. package/endpointReach.ts +80 -0
  14. package/index.ts +1 -0
  15. package/kindBranchScanner.test.ts +89 -0
  16. package/kindBranchScanner.ts +158 -0
  17. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  18. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  19. package/lint/no-oversized-list-limit.grit +73 -0
  20. package/maintainabilityBaseline.test.ts +280 -0
  21. package/maintainabilityBaseline.ts +220 -0
  22. package/maintainabilityContract.test.ts +846 -0
  23. package/maintainabilityContract.ts +404 -0
  24. package/maintainabilityExceptionScanner.test.ts +90 -0
  25. package/maintainabilityExceptionScanner.ts +85 -0
  26. package/maintainabilityLimits.ts +60 -0
  27. package/maintainabilityReport.test.ts +130 -0
  28. package/maintainabilityReport.ts +142 -0
  29. package/package.json +2 -2
  30. package/qualityScanner.test.ts +75 -0
  31. package/qualityScanner.ts +17 -1
  32. package/serviceLoopCallScanner.test.ts +56 -0
  33. package/serviceLoopCallScanner.ts +122 -0
  34. package/sourceComments.test.ts +45 -0
  35. package/sourceComments.ts +70 -0
  36. package/storeAccessScanner.test.ts +84 -0
  37. package/storeAccessScanner.ts +71 -0
  38. package/storeSource.ts +159 -0
  39. package/storeWriteScanner.test.ts +292 -0
  40. package/storeWriteScanner.ts +50 -0
  41. package/testOnlyExportScanner.test.ts +83 -0
  42. package/testOnlyExportScanner.ts +124 -0
  43. package/workflow/artifacts.ts +6 -1
  44. package/workflow/executor.ts +22 -7
  45. package/workflow/guardCatalog.test.ts +59 -0
  46. package/workflow/guardCatalog.ts +108 -0
  47. package/workflow/index.ts +4 -0
  48. package/workflow/interactionScaffold.ts +122 -0
  49. package/workflow/mutationScaffold.ts +519 -0
  50. package/workflow/mutationSource.test.ts +108 -0
  51. package/workflow/mutationSource.ts +219 -0
  52. package/workflow/plan.ts +54 -0
  53. package/workflow/render.ts +15 -1
  54. package/workflow/source.test.ts +130 -0
  55. package/workflow/source.ts +122 -14
  56. package/workflow/types.ts +17 -0
  57. package/workspaceLayout.ts +2 -0
  58. package/writePath.test.ts +109 -0
  59. package/writePath.ts +219 -0
  60. package/writePlacementScanner.test.ts +77 -0
  61. package/writePlacementScanner.ts +50 -0
  62. package/writingActionIndex.test.ts +99 -0
  63. 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
+ };
@@ -33,7 +33,13 @@ export const renderWorkflowList = (specs: readonly WorkflowSpec[]) =>
33
33
  [
34
34
  "# Akan Workflows",
35
35
  "",
36
- ...specs.flatMap((spec) => [`- \`${spec.name}\`: ${spec.description}`, ` - When: ${spec.whenToUse}`]),
36
+ ...specs.flatMap((spec) => [
37
+ `- \`${spec.name}\`: ${spec.description}`,
38
+ ` - When: ${spec.whenToUse}`,
39
+ ...(spec.replacesRules?.length
40
+ ? [` - Fixes: ${spec.replacesRules.map((rule) => `\`${rule}\``).join(", ")}`]
41
+ : []),
42
+ ]),
37
43
  "",
38
44
  ].join("\n");
39
45
 
@@ -46,6 +52,14 @@ export const renderWorkflowExplain = (spec: WorkflowSpec) =>
46
52
  "## When To Use",
47
53
  spec.whenToUse,
48
54
  "",
55
+ ...(spec.replacesRules?.length
56
+ ? [
57
+ "## Replaces Rules",
58
+ "This path is the fix when lint or the gate reports:",
59
+ ...spec.replacesRules.map((rule) => `- \`${rule}\``),
60
+ "",
61
+ ]
62
+ : []),
49
63
  "## Inputs",
50
64
  ...Object.entries(spec.inputs).map(
51
65
  ([name, input]) =>
@@ -1,9 +1,15 @@
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,
7
13
  insertSignalFactoryEntry,
8
14
  inspectDictionaryStructure,
9
15
  } from "./source";
@@ -173,3 +179,127 @@ export class BannerEndpoint extends endpoint(srv.banner, ({ mutation }) => ({
173
179
  expect(next).toBe(content);
174
180
  });
175
181
  });
182
+
183
+ describe("import editing", () => {
184
+ const signal = `import { endpoint, internal, slice } from "akanjs/signal";
185
+
186
+ import * as srv from "../srv";
187
+ `;
188
+
189
+ test("adds a name in Biome's order and keeps a type modifier", () => {
190
+ const content = 'import { type Dayjs, dayjs } from "akanjs/base";\n';
191
+
192
+ expect(ensureNamedImport(content, "akanjs/base", "ID")).toBe(
193
+ 'import { type Dayjs, dayjs, ID } from "akanjs/base";\n',
194
+ );
195
+ expect(ensureNamedImport(signal, "akanjs/signal", "None")).toContain(
196
+ 'import { endpoint, internal, None, slice } from "akanjs/signal";',
197
+ );
198
+ });
199
+
200
+ test("never adds a value name to a type-only import", () => {
201
+ const next = ensureNamedImport('import type { Guard } from "akanjs/signal";\n', "akanjs/signal", "Public");
202
+
203
+ expect(next).toContain('import type { Guard } from "akanjs/signal";');
204
+ expect(next).toContain('import { Public } from "akanjs/signal";');
205
+ });
206
+
207
+ test("puts a new declaration in its chunk, in source order", () => {
208
+ const withPackages = ensureNamedImport(
209
+ ensureNamedImport(signal, "akanjs/base", "ID"),
210
+ "@libs/shared/srvkit",
211
+ "Admin",
212
+ );
213
+ const withRelative = ensureImportDeclaration(withPackages, "../cnst", "* as cnst");
214
+
215
+ expect(withRelative).toBe(`import { Admin } from "@libs/shared/srvkit";
216
+ import { ID } from "akanjs/base";
217
+ import { endpoint, internal, slice } from "akanjs/signal";
218
+
219
+ import * as cnst from "../cnst";
220
+ import * as srv from "../srv";
221
+ `);
222
+ expect(
223
+ ensureImportDeclaration('import { serve } from "akanjs/service";\n\nexport {};\n', "../srv", "type * as srv"),
224
+ ).toBe('import { serve } from "akanjs/service";\n\nimport type * as srv from "../srv";\n\nexport {};\n');
225
+ });
226
+ });
227
+
228
+ describe("dictionary endpoint entries", () => {
229
+ const dictionary = `export const dictionary = modelDictionary(["en", "ko"])
230
+ .slice<TaskSlice>((fn) => ({}))
231
+ .endpoint<TaskEndpoint>((fn) => ({}))
232
+ .error({})
233
+ .translate({});
234
+ `;
235
+
236
+ test("inserts an entry into the endpoint stage and finds it again", () => {
237
+ const next = insertDictionaryEndpointEntry(
238
+ dictionary,
239
+ "Task",
240
+ "archiveTask",
241
+ 'archiveTask: fn(["Archive", "보관"]),',
242
+ );
243
+
244
+ expect(next).toContain('.endpoint<TaskEndpoint>((fn) => ({\n archiveTask: fn(["Archive", "보관"]),\n }))');
245
+ expect(hasDictionaryEndpointEntry(next ?? "", "Task", "archiveTask")).toBe(true);
246
+ expect(hasDictionaryEndpointEntry(dictionary, "Task", "archiveTask")).toBe(false);
247
+ });
248
+
249
+ test("answers null when the dictionary has no endpoint stage for the model", () => {
250
+ expect(insertDictionaryEndpointEntry(dictionary, "Note", "pinNote", "pinNote: fn([]),")).toBeNull();
251
+ });
252
+ });
253
+
254
+ describe("classMethodParameters", () => {
255
+ test("reads names and which parameters a caller may leave out", () => {
256
+ const content = `export class NoteService {
257
+ async pushNote(taskId: string, note?: string, retries = 3) {}
258
+ }`;
259
+
260
+ expect(classMethodParameters(content, "NoteService", "pushNote")).toEqual([
261
+ { name: "taskId", optional: false },
262
+ { name: "note", optional: true },
263
+ { name: "retries", optional: true },
264
+ ]);
265
+ expect(classMethodParameters(content, "NoteService", "missing")).toBeNull();
266
+ });
267
+ });
268
+
269
+ describe("appendTopLevelTestCases", () => {
270
+ const render = (itName: string, indent: string) => [`${indent}${itName}.todo("archives");`];
271
+
272
+ test("appends inside the outermost describe with the runner name the file imports", () => {
273
+ const content = `import { describe, expect, test } from "bun:test";
274
+
275
+ describe("Task Signal", () => {
276
+ test("creates", () => {
277
+ expect(1).toBe(1);
278
+ });
279
+ });
280
+ `;
281
+
282
+ expect(
283
+ appendTopLevelTestCases(content, "Task Signal", render),
284
+ ).toBe(`import { describe, expect, test } from "bun:test";
285
+
286
+ describe("Task Signal", () => {
287
+ test("creates", () => {
288
+ expect(1).toBe(1);
289
+ });
290
+ test.todo("archives");
291
+ });
292
+ `);
293
+ });
294
+
295
+ test("adds a describe when the file has none", () => {
296
+ const next = appendTopLevelTestCases('import { expect } from "bun:test";\n', "Task Signal", render);
297
+
298
+ expect(next).toBe(`import { describe, expect, it } from "bun:test";
299
+
300
+ describe("Task Signal", () => {
301
+ it.todo("archives");
302
+ });
303
+ `);
304
+ });
305
+ });
@@ -41,6 +41,7 @@ export const moduleSourcePaths = (moduleName: string) => {
41
41
  abstract: `lib/${moduleName}/${moduleName}.abstract.md`,
42
42
  constant: `lib/${moduleName}/${moduleName}.constant.ts`,
43
43
  dictionary: `lib/${moduleName}/${moduleName}.dictionary.ts`,
44
+ document: `lib/${moduleName}/${moduleName}.document.ts`,
44
45
  service: `lib/${moduleName}/${moduleName}.service.ts`,
45
46
  signal: `lib/${moduleName}/${moduleName}.signal.ts`,
46
47
  store: `lib/${moduleName}/${moduleName}.store.ts`,
@@ -189,20 +190,52 @@ export const normalizeFieldType = (typeName: string) => {
189
190
  return normalizedTypes[typeName.toLowerCase()] ?? typeName;
190
191
  };
191
192
 
193
+ //? Biome's organizeImports order: case-insensitive, an uppercase name before its lowercase twin.
194
+ const importOrder = (a: string, b: string) => {
195
+ const [lowerA, lowerB] = [a.toLowerCase(), b.toLowerCase()];
196
+ if (lowerA !== lowerB) return lowerA < lowerB ? -1 : 1;
197
+ return a < b ? -1 : a > b ? 1 : 0;
198
+ };
199
+
192
200
  export const ensureNamedImport = (content: string, moduleSpecifier: string, name: string) => {
193
201
  const source = sourceFileFor("source.ts", content);
194
- const namedImport = findNamedImport(source, moduleSpecifier);
195
- if (namedImport) {
196
- if (namedImport.names.includes(name)) return content;
197
- const nextNames = [...namedImport.names, name].sort();
198
- return spliceText(
199
- content,
200
- namedImport.namedBindingsStart,
201
- namedImport.namedBindingsEnd,
202
- `{ ${nextNames.join(", ")} }`,
202
+ const namedImport = findNamedImport(source, moduleSpecifier, { valueOnly: true });
203
+ if (!namedImport) return ensureImportDeclaration(content, moduleSpecifier, `{ ${name} }`);
204
+ if (namedImport.names.includes(name)) return content;
205
+ const nextElements = [...namedImport.elements, { name, text: name }].sort((a, b) => importOrder(a.name, b.name));
206
+ return spliceText(
207
+ content,
208
+ namedImport.namedBindingsStart,
209
+ namedImport.namedBindingsEnd,
210
+ `{ ${nextElements.map((element) => element.text).join(", ")} }`,
211
+ );
212
+ };
213
+
214
+ export const ensureImportDeclaration = (content: string, moduleSpecifier: string, clause: string) => {
215
+ const line = `import ${clause} from "${moduleSpecifier}";`;
216
+ if (content.includes(line)) return content;
217
+ const source = sourceFileFor("source.ts", content);
218
+ const imports = source.statements
219
+ .filter((statement): statement is ts.ImportDeclaration => ts.isImportDeclaration(statement))
220
+ .flatMap((statement) =>
221
+ ts.isStringLiteral(statement.moduleSpecifier)
222
+ ? [{ specifier: statement.moduleSpecifier.text, start: statement.getStart(source), end: statement.getEnd() }]
223
+ : [],
203
224
  );
225
+ const isRelative = (specifier: string) => specifier.startsWith(".");
226
+ const peers = imports.filter((entry) => isRelative(entry.specifier) === isRelative(moduleSpecifier));
227
+ const before = peers.find((entry) => importOrder(entry.specifier, moduleSpecifier) > 0);
228
+ if (before) {
229
+ const insertAt = lineStartAt(content, before.start);
230
+ return spliceText(content, insertAt, insertAt, `${line}\n`);
231
+ }
232
+ const after = peers.at(-1) ?? (isRelative(moduleSpecifier) ? imports.at(-1) : undefined);
233
+ if (after) {
234
+ const insertAt = lineEndAt(content, after.end);
235
+ return spliceText(content, insertAt, insertAt, peers.length ? `${line}\n` : `\n${line}\n`);
204
236
  }
205
- return `import { ${name} } from "${moduleSpecifier}";\n${content}`;
237
+ const insertAt = imports[0] ? lineStartAt(content, imports[0].start) : 0;
238
+ return spliceText(content, insertAt, insertAt, `${line}\n\n`);
206
239
  };
207
240
 
208
241
  export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
@@ -382,6 +415,7 @@ interface ArrayInsertionLocator {
382
415
 
383
416
  interface NamedImportLocator {
384
417
  names: string[];
418
+ elements: { name: string; text: string }[];
385
419
  namedBindingsStart: number;
386
420
  namedBindingsEnd: number;
387
421
  }
@@ -499,16 +533,23 @@ const dictionaryChainOrderValid = (chainMethods: readonly string[]) => {
499
533
  return true;
500
534
  };
501
535
 
502
- const findDictionaryModelObject = (source: ts.SourceFile, moduleClassName: string): DictionaryModelLocator | null => {
536
+ const findDictionaryModelObject = (source: ts.SourceFile, moduleClassName: string) =>
537
+ findDictionaryStageObject(source, "model", moduleClassName);
538
+
539
+ const findDictionaryStageObject = (
540
+ source: ts.SourceFile,
541
+ stage: string,
542
+ typeName: string,
543
+ ): DictionaryModelLocator | null => {
503
544
  let locator: DictionaryModelLocator | null = null;
504
545
  const visit = (node: ts.Node) => {
505
546
  if (locator) return;
506
- if (!ts.isCallExpression(node) || callExpressionName(node) !== "model") {
547
+ if (!ts.isCallExpression(node) || callExpressionName(node) !== stage) {
507
548
  ts.forEachChild(node, visit);
508
549
  return;
509
550
  }
510
551
  const typeArgument = node.typeArguments?.[0];
511
- if (!typeArgument || typeArgument.getText(source) !== moduleClassName) {
552
+ if (!typeArgument || typeArgument.getText(source) !== typeName) {
512
553
  ts.forEachChild(node, visit);
513
554
  return;
514
555
  }
@@ -556,14 +597,20 @@ const findLightProjectionArray = (source: ts.SourceFile, moduleClassName: string
556
597
  return locator;
557
598
  };
558
599
 
559
- const findNamedImport = (source: ts.SourceFile, moduleSpecifier: string): NamedImportLocator | null => {
600
+ const findNamedImport = (
601
+ source: ts.SourceFile,
602
+ moduleSpecifier: string,
603
+ { valueOnly = false }: { valueOnly?: boolean } = {},
604
+ ): NamedImportLocator | null => {
560
605
  for (const statement of source.statements) {
561
606
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
562
607
  if (statement.moduleSpecifier.text !== moduleSpecifier) continue;
608
+ if (valueOnly && statement.importClause?.isTypeOnly) continue;
563
609
  const namedBindings = statement.importClause?.namedBindings;
564
610
  if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
565
611
  return {
566
612
  names: namedBindings.elements.map((element) => element.name.text),
613
+ elements: namedBindings.elements.map((element) => ({ name: element.name.text, text: element.getText(source) })),
567
614
  namedBindingsStart: namedBindings.getStart(source),
568
615
  namedBindingsEnd: namedBindings.getEnd(),
569
616
  };
@@ -816,6 +863,27 @@ export const hasDictionaryModelField = (content: string, moduleClassName: string
816
863
  return findDictionaryModelObject(source, moduleClassName)?.fields.some((field) => field.name === fieldName) ?? false;
817
864
  };
818
865
 
866
+ export const hasDictionaryEndpointEntry = (content: string, moduleClassName: string, entryName: string) => {
867
+ const source = sourceFileFor("dictionary.ts", content);
868
+ return (
869
+ findDictionaryStageObject(source, "endpoint", `${moduleClassName}Endpoint`)?.fields.some(
870
+ (field) => field.name === entryName,
871
+ ) ?? false
872
+ );
873
+ };
874
+
875
+ export const insertDictionaryEndpointEntry = (
876
+ content: string,
877
+ moduleClassName: string,
878
+ entryName: string,
879
+ entryText: string,
880
+ ) => {
881
+ const source = sourceFileFor("dictionary.ts", content);
882
+ const locator = findDictionaryStageObject(source, "endpoint", `${moduleClassName}Endpoint`);
883
+ if (!locator) return null;
884
+ return insertOrderedFieldLine(content, locator, entryName, entryText, { fieldIndent: " ", closingIndent: " " });
885
+ };
886
+
819
887
  export const ensureConstantTypeImport = (content: string, constantPath: string, typeName: string) => {
820
888
  if (new RegExp(`import type \\{[^}]*\\b${typeName}\\b[^}]*\\} from "${constantPath}";`).test(content)) return content;
821
889
  const importPattern = new RegExp(`import type \\{ ([^}]+) \\} from "${constantPath}";`);
@@ -883,6 +951,19 @@ export const hasClassMethod = (content: string, className: string, methodName: s
883
951
  );
884
952
  };
885
953
 
954
+ export const classMethodParameters = (content: string, className: string, methodName: string) => {
955
+ const source = sourceFileFor("service.ts", content);
956
+ const method = findClassDeclaration(source, className)?.members.find(
957
+ (member): member is ts.MethodDeclaration => ts.isMethodDeclaration(member) && nodeName(member.name) === methodName,
958
+ );
959
+ return (
960
+ method?.parameters.map((parameter) => ({
961
+ name: nodeName(parameter.name),
962
+ optional: !!parameter.questionToken || !!parameter.initializer || !!parameter.dotDotDotToken,
963
+ })) ?? null
964
+ );
965
+ };
966
+
886
967
  export const insertClassMethod = (content: string, className: string, methodBlock: string): string | null => {
887
968
  const source = sourceFileFor("service.ts", content);
888
969
  const node = findClassDeclaration(source, className);
@@ -976,3 +1057,30 @@ export const insertSignalFactoryEntry = (
976
1057
  if (withEntry === content) return null;
977
1058
  return paramEdit === "unchanged" ? withEntry : spliceText(withEntry, paramEdit.start, paramEdit.end, paramEdit.text);
978
1059
  };
1060
+
1061
+ export const appendTopLevelTestCases = (
1062
+ content: string,
1063
+ describeTitle: string,
1064
+ render: (itName: string, indent: string) => string[],
1065
+ ) => {
1066
+ const bunImport = findNamedImport(sourceFileFor("test.ts", content), "bun:test", { valueOnly: true });
1067
+ const itName = !bunImport?.names.includes("it") && bunImport?.names.includes("test") ? "test" : "it";
1068
+ const imported = ensureNamedImport(content, "bun:test", itName);
1069
+ const source = sourceFileFor("test.ts", imported);
1070
+ const body = source.statements.flatMap((statement) => {
1071
+ if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return [];
1072
+ if (expressionName(statement.expression.expression) !== "describe") return [];
1073
+ const callback = factoryArrowOf(statement.expression);
1074
+ return callback && ts.isBlock(callback.body) ? [callback.body] : [];
1075
+ })[0];
1076
+ if (!body) {
1077
+ const block = [`describe(${JSON.stringify(describeTitle)}, () => {`, ...render(itName, " "), "});", ""];
1078
+ return `${ensureNamedImport(imported, "bun:test", "describe").trimEnd()}\n\n${block.join("\n")}`;
1079
+ }
1080
+ const firstStatement = body.statements[0];
1081
+ const indent = firstStatement
1082
+ ? lineIndentAt(imported, firstStatement.getStart(source))
1083
+ : `${lineIndentAt(imported, body.getStart(source))} `;
1084
+ const insertAt = lineStartAt(imported, body.getEnd() - 1);
1085
+ return spliceText(imported, insertAt, insertAt, `${render(itName, indent).join("\n")}\n`);
1086
+ };