@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.0

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 (188) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +105 -0
  8. package/akanConfig/akanConfig.ts +21 -1
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/akanMcpContract.test.ts +28 -1
  13. package/akanMcpContract.ts +51 -3
  14. package/applicationBuildRunner.test.ts +53 -0
  15. package/applicationBuildRunner.ts +7 -4
  16. package/artifact/implicitRootLayout.ts +4 -2
  17. package/biome.base.json +4 -1
  18. package/cloud/globalConfig.test.ts +34 -0
  19. package/cloud/globalConfig.ts +2 -2
  20. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  21. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  22. package/codeAgent/agent/CodeAgent.ts +11 -0
  23. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  24. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  25. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  26. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  27. package/codeAgent/agent/SubagentPool.ts +5 -0
  28. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  29. package/codeAgent/agent/akanCodePaths.ts +8 -1
  30. package/codeAgent/agent/akanSystemPrompt.ts +5 -1
  31. package/codeAgent/agent/codeAgent.test.ts +41 -2
  32. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  33. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  34. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  35. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  36. package/codeAgent/agent/stringArg.ts +1 -1
  37. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  38. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  39. package/codeAgent/index.ts +2 -2
  40. package/codeAgent/skills/akan-agent/SKILL.md +43 -0
  41. package/codeAgent/skills/akan-code/SKILL.md +38 -11
  42. package/codeAgent/skills/akan-debug/SKILL.md +41 -6
  43. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  44. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  45. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  46. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  47. package/codeAgent/skills/akan-query/SKILL.md +39 -0
  48. package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
  49. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  50. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  51. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  52. package/codeAgent/tools/AkanCli.ts +2 -1
  53. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  54. package/codeAgent/tools/AkanEditScope.ts +8 -3
  55. package/codeAgent/tools/AkanToolPack.ts +4 -10
  56. package/codeAgent/tools/AkanVerifier.ts +13 -31
  57. package/codeAgent/tools/McpServerConfig.ts +6 -4
  58. package/codeAgent/tools/McpSignIn.ts +7 -7
  59. package/codeAgent/tools/McpTokenStore.ts +63 -16
  60. package/codeAgent/tools/McpToolPack.ts +8 -1
  61. package/codeAgent/tools/WebToolPack.ts +50 -6
  62. package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
  63. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  64. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  65. package/commandDecorators/command.ts +4 -1
  66. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  67. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  68. package/commentDensityScanner.test.ts +121 -0
  69. package/commentDensityScanner.ts +101 -0
  70. package/componentSizeScanner.test.ts +36 -0
  71. package/componentSizeScanner.ts +52 -0
  72. package/componentWriteChainScanner.test.ts +137 -0
  73. package/componentWriteChainScanner.ts +104 -0
  74. package/conventionSet.test.ts +73 -0
  75. package/conventionSet.ts +67 -0
  76. package/createTunnel.test.ts +200 -2
  77. package/createTunnel.ts +80 -6
  78. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  79. package/deprecatedCommentScanner.test.ts +54 -0
  80. package/deprecatedCommentScanner.ts +30 -0
  81. package/endpointReach.test.ts +58 -0
  82. package/endpointReach.ts +80 -0
  83. package/envSeed.test.ts +90 -0
  84. package/envSeed.ts +68 -0
  85. package/executors.test.ts +19 -5
  86. package/executors.ts +59 -4
  87. package/fileSys.test.ts +30 -2
  88. package/fileSys.ts +14 -0
  89. package/formSetterScanner.test.ts +44 -1
  90. package/formSetterScanner.ts +53 -8
  91. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  92. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  93. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  94. package/frontendBuild/optionalPeers.test.ts +132 -0
  95. package/index.ts +1 -0
  96. package/integration/devStability.integration.test.ts +9 -5
  97. package/integration/ssrMemoryProbe.ts +6 -0
  98. package/kindBranchScanner.test.ts +89 -0
  99. package/kindBranchScanner.ts +158 -0
  100. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  101. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  102. package/lint/no-oversized-list-limit.grit +73 -0
  103. package/lint/no-unguarded-endpoint.grit +5 -5
  104. package/linter.test.ts +12 -0
  105. package/linter.ts +35 -12
  106. package/maintainabilityBaseline.test.ts +280 -0
  107. package/maintainabilityBaseline.ts +220 -0
  108. package/maintainabilityContract.test.ts +846 -0
  109. package/maintainabilityContract.ts +404 -0
  110. package/maintainabilityExceptionScanner.test.ts +90 -0
  111. package/maintainabilityExceptionScanner.ts +85 -0
  112. package/maintainabilityLimits.ts +60 -0
  113. package/maintainabilityReport.test.ts +130 -0
  114. package/maintainabilityReport.ts +153 -0
  115. package/mobile/desktopRelease.test.ts +1 -1
  116. package/mobile/desktopRelease.ts +3 -1
  117. package/mobile/desktopServerStage.test.ts +1 -0
  118. package/mobile/desktopServerStage.ts +3 -1
  119. package/mobile/nativeApp.test.ts +20 -5
  120. package/mobile/nativeApp.ts +4 -1
  121. package/mobile/nativeConfig.test.ts +209 -5
  122. package/mobile/nativeConfig.ts +127 -6
  123. package/mobile/nativePublicEnv.test.ts +2 -0
  124. package/mobile/nativePublicEnv.ts +13 -0
  125. package/package.json +4 -2
  126. package/platformTest/LinuxDockerTarget.ts +3 -0
  127. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  128. package/preview/PreviewView.ts +219 -0
  129. package/preview/index.ts +2 -0
  130. package/preview/previewView.test.ts +76 -0
  131. package/prompter.ts +7 -0
  132. package/qualityScanner.test.ts +75 -0
  133. package/qualityScanner.ts +17 -1
  134. package/routeSourceValidator.test.ts +11 -2
  135. package/routeSourceValidator.ts +14 -2
  136. package/serviceLoopCallScanner.test.ts +56 -0
  137. package/serviceLoopCallScanner.ts +122 -0
  138. package/shellOnlyEnv.test.ts +65 -0
  139. package/shellOnlyEnv.ts +54 -0
  140. package/signalMutationIndex.test.ts +15 -0
  141. package/signalMutationIndex.ts +23 -82
  142. package/sourceComments.test.ts +45 -0
  143. package/sourceComments.ts +70 -0
  144. package/sshHostKeys.ts +59 -0
  145. package/storeAccessScanner.test.ts +84 -0
  146. package/storeAccessScanner.ts +71 -0
  147. package/storeSource.ts +160 -0
  148. package/storeWriteScanner.test.ts +292 -0
  149. package/storeWriteScanner.ts +50 -0
  150. package/subspace.test.ts +10 -0
  151. package/subspaceClone.ts +3 -1
  152. package/subspaceConfig.ts +6 -0
  153. package/testOnlyExportScanner.test.ts +83 -0
  154. package/testOnlyExportScanner.ts +124 -0
  155. package/transforms/barrelImportsPlugin.ts +10 -4
  156. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  157. package/transforms/mcpAppPageTrimmer.ts +5 -20
  158. package/transforms/pureLazyMarker.ts +1 -1
  159. package/typeChecker.ts +32 -0
  160. package/typecheck/projectTypecheck.test.ts +18 -0
  161. package/typecheck/projectTypecheck.ts +29 -5
  162. package/typecheck/typecheck.proc.ts +6 -0
  163. package/typecheck/typecheckFailure.ts +13 -0
  164. package/types.ts +1 -0
  165. package/workflow/artifacts.ts +16 -2
  166. package/workflow/executor.ts +22 -7
  167. package/workflow/guardCatalog.test.ts +59 -0
  168. package/workflow/guardCatalog.ts +108 -0
  169. package/workflow/index.ts +4 -0
  170. package/workflow/interactionScaffold.ts +122 -0
  171. package/workflow/mutationScaffold.ts +533 -0
  172. package/workflow/mutationSource.test.ts +108 -0
  173. package/workflow/mutationSource.ts +219 -0
  174. package/workflow/plan.ts +82 -0
  175. package/workflow/render.ts +19 -4
  176. package/workflow/source.test.ts +185 -0
  177. package/workflow/source.ts +151 -24
  178. package/workflow/sourceAst.ts +10 -48
  179. package/workflow/types.ts +18 -1
  180. package/workspaceLayout.ts +2 -0
  181. package/writePath.test.ts +109 -0
  182. package/writePath.ts +219 -0
  183. package/writePlacementScanner.test.ts +77 -0
  184. package/writePlacementScanner.ts +50 -0
  185. package/writingActionIndex.test.ts +99 -0
  186. package/writingActionIndex.ts +63 -0
  187. package/codeAgent/feedback/PreviewView.ts +0 -95
  188. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -0,0 +1,533 @@
1
+ import { lowerlize, modelNames } from "akanjs/common";
2
+ import type { Sys } from "../commandDecorators";
3
+ import { GuardCatalog } from "./guardCatalog";
4
+ import { type MutationActingUser, type MutationCall, MutationSource } from "./mutationSource";
5
+ import {
6
+ appendTopLevelTestCases,
7
+ classMethodParameters,
8
+ ensureImportDeclaration,
9
+ ensureNamedImport,
10
+ hasClassMethod,
11
+ hasDictionaryEndpointEntry,
12
+ hasSignalFactoryEntry,
13
+ hasSourceParseErrors,
14
+ insertClassMethod,
15
+ insertDictionaryEndpointEntry,
16
+ insertSignalFactoryEntry,
17
+ moduleComponentName,
18
+ moduleSourcePaths,
19
+ } from "./source";
20
+ import type { ScaffoldFile, WorkflowDiagnostic, WorkflowRecommendation } from "./types";
21
+
22
+ export interface MutationScaffoldInput {
23
+ module: string | null;
24
+ mutation: string | null;
25
+ guards?: readonly string[] | null;
26
+ withCalls?: readonly string[] | null;
27
+ afterCalls?: readonly string[] | null;
28
+ }
29
+
30
+ interface ResolvedCall extends MutationCall {
31
+ injection: { field: string; typeRef: string } | null;
32
+ }
33
+
34
+ interface ServiceSource {
35
+ className: string;
36
+ content: string;
37
+ typeRef: string;
38
+ }
39
+
40
+ export class MutationScaffold {
41
+ static readonly #documentMethods = new Set(["isModified", "refresh", "remove", "save", "set", "toJSON", "toObject"]);
42
+ static readonly #serviceMethods = new Set(["afterCommit", "logger", "onDestroy", "onInit", "transaction"]);
43
+ static readonly #callPattern = /^_?([a-z][A-Za-z0-9]*)\.([A-Za-z_$][\w$]*)$/;
44
+ readonly #sys: Sys;
45
+ readonly #catalog: GuardCatalog;
46
+ readonly #strict: boolean;
47
+ readonly #diagnostics: WorkflowDiagnostic[] = [];
48
+
49
+ constructor(sys: Sys, catalog: GuardCatalog, { strict }: { strict: boolean }) {
50
+ this.#sys = sys;
51
+ this.#catalog = catalog;
52
+ this.#strict = strict;
53
+ }
54
+
55
+ static reservedNamesOf(modelClassName: string) {
56
+ const names = modelNames(lowerlize(modelClassName));
57
+ return new Set([
58
+ names.model,
59
+ names.lightModel,
60
+ names.createModel,
61
+ names.updateModel,
62
+ names.removeModel,
63
+ names.viewModel,
64
+ names.editModel,
65
+ names.mergeModel,
66
+ names.getModel,
67
+ names.loadModel,
68
+ names.setModel,
69
+ names.resetModel,
70
+ names.newModel,
71
+ names.submitModel,
72
+ names.invalidateModel,
73
+ names.createModelInForm,
74
+ names.updateModelInForm,
75
+ names.loadModelMany,
76
+ names.getModelView,
77
+ names.getModelEdit,
78
+ names.addModelFiles,
79
+ names.checkModelSubmitable,
80
+ names.writeOnModel,
81
+ names.loadModelFormDraft,
82
+ names.restoreModelFormDraft,
83
+ names.discardModelFormDraft,
84
+ ]);
85
+ }
86
+
87
+ static generates(modelClassName: string, name: string) {
88
+ return (
89
+ MutationScaffold.reservedNamesOf(modelClassName).has(name) ||
90
+ new RegExp(`^(init${modelClassName}|get${modelClassName}Init|(set|add|sub|upload)\\w+On${modelClassName}$)`).test(
91
+ name,
92
+ )
93
+ );
94
+ }
95
+
96
+ static inputDiagnostics(input: MutationScaffoldInput): WorkflowDiagnostic[] {
97
+ const diagnostics: WorkflowDiagnostic[] = [];
98
+ const error = (code: string, inputName: string, message: string) =>
99
+ diagnostics.push({ severity: "error", code, input: inputName, message });
100
+ const { module, mutation } = input;
101
+ if (mutation && !/^[a-z][A-Za-z0-9]*$/.test(mutation))
102
+ error("add-mutation-name-invalid", "mutation", `Mutation name "${mutation}" must be a camelCase identifier.`);
103
+ if (module && mutation) {
104
+ const modelClassName = moduleComponentName(module);
105
+ if (MutationScaffold.generates(modelClassName, mutation))
106
+ error(
107
+ "add-mutation-name-reserved",
108
+ "mutation",
109
+ `"${mutation}" collides with a name Akan generates for ${modelClassName} (CRUD endpoints, store actions, form setters). Name the act, e.g. archive${modelClassName}.`,
110
+ );
111
+ const chainMethod = MutationSource.chainMethodOf(mutation, modelClassName);
112
+ if (MutationScaffold.#documentMethods.has(chainMethod))
113
+ error(
114
+ "add-mutation-name-reserved",
115
+ "mutation",
116
+ `"${mutation}" drops to the document chain method ${chainMethod}(), which every document already has.`,
117
+ );
118
+ else if (MutationScaffold.#serviceMethods.has(chainMethod))
119
+ error(
120
+ "add-mutation-name-reserved",
121
+ "mutation",
122
+ `"${mutation}" drops to the service method ${chainMethod}(), which every service already has.`,
123
+ );
124
+ }
125
+ const guards = input.guards ?? [];
126
+ if (!guards.length)
127
+ error(
128
+ "add-mutation-guard-missing",
129
+ "guard",
130
+ "Name the guard(s) that admit a caller (guard=Admin, guard=Every,Person); add-mutation has no default guard.",
131
+ );
132
+ for (const guard of guards) {
133
+ if (guard === "None")
134
+ error(
135
+ "add-mutation-guard-none",
136
+ "guard",
137
+ "None answers no caller, so the endpoint would ship dead; name the guard that admits the intended caller.",
138
+ );
139
+ else if (!/^[A-Z][A-Za-z0-9]*$/.test(guard))
140
+ error("add-mutation-guard-invalid", "guard", `Guard "${guard}" must be a guard class name.`);
141
+ }
142
+ for (const [inputName, calls] of [
143
+ ["with", input.withCalls],
144
+ ["after", input.afterCalls],
145
+ ] as const) {
146
+ for (const call of calls ?? [])
147
+ if (!MutationScaffold.#callPattern.test(call))
148
+ error(
149
+ "add-mutation-call-invalid",
150
+ inputName,
151
+ `"${call}" must name a service method as <serviceRef>.<method>, e.g. courtPc.pushPcCommand.`,
152
+ );
153
+ }
154
+ return diagnostics;
155
+ }
156
+
157
+ static async build(sys: Sys, input: MutationScaffoldInput, { strict = true }: { strict?: boolean } = {}) {
158
+ return await new MutationScaffold(sys, await GuardCatalog.load(sys), { strict }).#build(input);
159
+ }
160
+
161
+ async #build(input: MutationScaffoldInput) {
162
+ const files: ScaffoldFile[] = [];
163
+ const recommendations: WorkflowRecommendation[] = [];
164
+ this.#diagnostics.push(...MutationScaffold.inputDiagnostics(input));
165
+ const { module, mutation } = input;
166
+ const guards = [...new Set(input.guards ?? [])];
167
+ const guardSpecifiers = this.#resolveGuards(guards);
168
+ if (this.#hasGuardError())
169
+ recommendations.push({
170
+ code: "add-mutation-guard-choices",
171
+ kind: "input-guidance",
172
+ confidence: "high",
173
+ message: `Guards this target reaches: ${this.#catalog.names.join(", ") || "none"}.`,
174
+ action: "Re-plan with guard set to one or more of these names.",
175
+ });
176
+ if (!module || !mutation || this.#hasError()) return this.#result(files, recommendations);
177
+
178
+ const paths = moduleSourcePaths(module);
179
+ const sourcePaths = [paths.document, paths.service, paths.signal, paths.store, paths.dictionary];
180
+ const present = await Promise.all(sourcePaths.map((path) => this.#sys.exists(path)));
181
+ const missing = sourcePaths.filter((_, idx) => !present[idx]);
182
+ if (missing.length) {
183
+ this.#report(
184
+ "add-mutation-module-missing",
185
+ `Module ${module} lacks ${missing.join(", ")}; add-mutation writes into an existing database module (create-module first).`,
186
+ { input: "module" },
187
+ );
188
+ return this.#result(files, recommendations);
189
+ }
190
+
191
+ const acting = this.#catalog.actingUserOf(guards);
192
+ if (acting)
193
+ recommendations.push({
194
+ code: "add-mutation-ownership",
195
+ kind: "manual-action",
196
+ confidence: "high",
197
+ message: `${guards.join(" and ")} admits any signed-in caller, so the service receives ${acting.serviceParam} and a TODO where it must refuse a caller who may not act on this ${module}.`,
198
+ action: `Check ${acting.serviceParam} against the ${module} before the chain, or guard the mutation with a Can<Verb><Model> resource guard.`,
199
+ });
200
+ const source = new MutationSource({
201
+ module,
202
+ mutation,
203
+ guards,
204
+ acting,
205
+ withCalls: [],
206
+ afterCalls: [],
207
+ });
208
+ const [document, service, signal, store, dictionary] = await Promise.all(
209
+ sourcePaths.map((path) => this.#sys.readFile(path)),
210
+ );
211
+ await this.#checkNameFree(source, { service, signal, store, dictionary });
212
+ const withCalls = await this.#resolveCalls(source, input.withCalls ?? [], "with", acting);
213
+ const afterCalls = await this.#resolveCalls(source, input.afterCalls ?? [], "after", acting);
214
+ if (this.#hasError()) return this.#result(files, recommendations);
215
+
216
+ const shaped = new MutationSource({ module, mutation, guards, acting, withCalls, afterCalls });
217
+ const testPath = `lib/${module}/${module}.signal.test.ts`;
218
+ const testExists = await this.#sys.exists(testPath);
219
+ const edits: [string, string | null, string, ScaffoldFile["action"]][] = [
220
+ [paths.document, this.#editDocument(document, shaped), "Document chain method was added.", "modify"],
221
+ [
222
+ paths.service,
223
+ this.#editService(service, shaped, [...withCalls, ...afterCalls]),
224
+ "Service mutation was added.",
225
+ "modify",
226
+ ],
227
+ [paths.signal, this.#editSignal(signal, shaped, guardSpecifiers, acting), "Signal mutation was added.", "modify"],
228
+ [paths.store, this.#editStore(store, shaped), "Store action was added.", "modify"],
229
+ [paths.dictionary, this.#editDictionary(dictionary, shaped), "Dictionary endpoint entry was added.", "modify"],
230
+ [
231
+ testPath,
232
+ testExists
233
+ ? appendTopLevelTestCases(
234
+ await this.#sys.readFile(testPath),
235
+ `${shaped.modelClassName} Signal`,
236
+ (itName, indent) => shaped.testCaseLines(itName, indent),
237
+ )
238
+ : shaped.testFile,
239
+ "Signal test placeholders were added.",
240
+ testExists ? "modify" : "create",
241
+ ],
242
+ ];
243
+ for (const [path, content, reason, action] of edits) {
244
+ if (content === null) continue;
245
+ if (hasSourceParseErrors(content, path))
246
+ this.#report("add-mutation-post-edit-parse-failed", `Edited ${path} did not parse; refusing to write source.`, {
247
+ severity: "error",
248
+ });
249
+ files.push({ path, content, reason, action });
250
+ }
251
+ return this.#result(files, recommendations);
252
+ }
253
+
254
+ #result(files: ScaffoldFile[], recommendations: WorkflowRecommendation[]) {
255
+ const diagnostics = this.#diagnostics;
256
+ return { diagnostics, recommendations, files: this.#hasError() ? [] : files };
257
+ }
258
+
259
+ #hasError() {
260
+ return this.#diagnostics.some((diagnostic) => diagnostic.severity === "error");
261
+ }
262
+
263
+ #hasGuardError() {
264
+ return this.#diagnostics.some((diagnostic) => diagnostic.code.startsWith("add-mutation-guard"));
265
+ }
266
+
267
+ #report(
268
+ code: string,
269
+ message: string,
270
+ { input, severity }: { input?: string; severity?: WorkflowDiagnostic["severity"] } = {},
271
+ ) {
272
+ this.#diagnostics.push({
273
+ severity: severity ?? (this.#strict ? "error" : "warning"),
274
+ code,
275
+ message,
276
+ input,
277
+ failureScope: "source-change",
278
+ });
279
+ }
280
+
281
+ #resolveGuards(guards: readonly string[]) {
282
+ const specifiers = new Map<string, string>();
283
+ for (const guard of guards) {
284
+ if (guard === "None") continue;
285
+ const { specifier, ambiguous } = this.#catalog.resolve(guard);
286
+ if (ambiguous)
287
+ this.#report("add-mutation-guard-ambiguous", `Guard "${guard}" is exported by ${ambiguous.join(" and ")}.`, {
288
+ input: "guard",
289
+ severity: "error",
290
+ });
291
+ else if (!specifier)
292
+ this.#report(
293
+ "add-mutation-guard-unknown",
294
+ `Guard "${guard}" is not exported by ${this.#sys.type}s/${this.#sys.name}/srvkit, the srvkit of a lib it reaches (${this.#catalog.reachedLibs.join(", ") || "none"}), or akanjs/signal.`,
295
+ { input: "guard", severity: "error" },
296
+ );
297
+ else specifiers.set(guard, specifier);
298
+ }
299
+ return specifiers;
300
+ }
301
+
302
+ async #checkNameFree(source: MutationSource, files: Record<"service" | "signal" | "store" | "dictionary", string>) {
303
+ const { mutation } = source.spec;
304
+ const { modelClassName } = source;
305
+ const taken = [
306
+ hasClassMethod(files.service, `${modelClassName}Service`, source.serviceMethod) &&
307
+ `${modelClassName}Service.${source.serviceMethod}()`,
308
+ ["Internal", "Slice", "Endpoint"].some((kind) =>
309
+ hasSignalFactoryEntry(files.signal, `${modelClassName}${kind}`, mutation),
310
+ ) && `${source.spec.module}.signal.ts`,
311
+ hasClassMethod(files.store, `${modelClassName}Store`, mutation) && `${modelClassName}Store`,
312
+ hasDictionaryEndpointEntry(files.dictionary, modelClassName, mutation) && `${source.spec.module}.dictionary.ts`,
313
+ ...(await this.#signalsDeclaring(mutation, source.spec.module)),
314
+ ].filter((place): place is string => !!place);
315
+ if (taken.length)
316
+ this.#report("add-mutation-name-exists", `"${mutation}" already exists in ${taken.join(", ")}.`, {
317
+ input: "mutation",
318
+ severity: "error",
319
+ });
320
+ }
321
+
322
+ //? Every store is mixed into one root and every endpoint into one `fetch`, so a name another module or a reached lib
323
+ //? generates or declares refuses to boot (`[DI:fetch]`) however far away it lives.
324
+ async #signalsDeclaring(mutation: string, ownModule: string) {
325
+ const pattern = new RegExp(`^\\s*${mutation}:\\s*(mutation|query|pubsub|message)\\(`, "m");
326
+ const roots = [`${this.#sys.type}s/${this.#sys.name}`, ...this.#catalog.reachedLibs.map((lib) => `libs/${lib}`)];
327
+ const hits = await Promise.all(
328
+ roots.map(async (root) => {
329
+ if (!(await this.#sys.workspace.exists(`${root}/lib`))) return [];
330
+ const folders = (await this.#sys.workspace.readdir(`${root}/lib`)).filter(
331
+ (folder) => !folder.includes(".") && !folder.startsWith("__") && !(root === roots[0] && folder === ownModule),
332
+ );
333
+ return await Promise.all(
334
+ folders.map(async (folder) => {
335
+ const name = folder.replace(/^_+/, "");
336
+ const base = `${root}/lib/${folder}/${name}`;
337
+ if (
338
+ (await this.#sys.workspace.exists(`${base}.document.ts`)) &&
339
+ MutationScaffold.generates(moduleComponentName(name), mutation)
340
+ )
341
+ return `${root}/lib/${folder} (generated for ${moduleComponentName(name)})`;
342
+ if (!(await this.#sys.workspace.exists(`${base}.signal.ts`))) return null;
343
+ return pattern.test(await this.#sys.workspace.readFile(`${base}.signal.ts`)) ? `${base}.signal.ts` : null;
344
+ }),
345
+ );
346
+ }),
347
+ );
348
+ return hits.flat().filter((hit): hit is string => !!hit);
349
+ }
350
+
351
+ async #resolveCalls(
352
+ source: MutationSource,
353
+ calls: readonly string[],
354
+ inputName: "with" | "after",
355
+ acting: MutationActingUser | null,
356
+ ) {
357
+ const resolved: ResolvedCall[] = [];
358
+ for (const call of calls) {
359
+ const [rawRef = "", method = ""] = call.split(".");
360
+ const ref = rawRef.replace(/^_+/, "");
361
+ const isOwn = ref === source.model;
362
+ const service = await this.#serviceSourceOf(ref, isOwn);
363
+ if (!service) {
364
+ this.#report(
365
+ "add-mutation-call-service-missing",
366
+ `${inputName}: no ${moduleComponentName(ref)}Service in ${this.#sys.name} or the libs it reaches.`,
367
+ { input: inputName },
368
+ );
369
+ continue;
370
+ }
371
+ const params = this.#parametersOf(service, ref, method);
372
+ if (!params) {
373
+ this.#report("add-mutation-call-method-missing", `${inputName}: ${service.className} has no ${method}().`, {
374
+ input: inputName,
375
+ });
376
+ continue;
377
+ }
378
+ const scope = new Map<string, string>([
379
+ [source.idParam, `${source.model}.id`],
380
+ [source.model, source.model],
381
+ ...(isOwn ? [["id", `${source.model}.id`] as [string, string]] : []),
382
+ ...(acting ? [[acting.serviceParam, acting.serviceParam] as [string, string]] : []),
383
+ ]);
384
+ const args: string[] = [];
385
+ for (const param of params) {
386
+ const value = param.name ? scope.get(param.name) : undefined;
387
+ if (value) {
388
+ args.push(value);
389
+ continue;
390
+ }
391
+ if (!param.optional)
392
+ this.#report(
393
+ "add-mutation-call-args-unresolved",
394
+ `${inputName}: ${service.className}.${method}() takes "${param.name ?? "?"}", which add-mutation cannot supply. Name a method taking the ${source.model}, its id or the acting user, or call this one by hand after apply.`,
395
+ { input: inputName, severity: "error" },
396
+ );
397
+ break;
398
+ }
399
+ const field = isOwn ? null : `${ref}Service`;
400
+ resolved.push({
401
+ ref,
402
+ method,
403
+ field,
404
+ args,
405
+ injection: field ? { field, typeRef: service.typeRef } : null,
406
+ });
407
+ }
408
+ return resolved;
409
+ }
410
+
411
+ async #serviceSourceOf(ref: string, isOwn: boolean): Promise<ServiceSource | null> {
412
+ const className = `${moduleComponentName(ref)}Service`;
413
+ const scopes = [
414
+ { root: `${this.#sys.type}s/${this.#sys.name}`, typeRef: `srv.${className}` },
415
+ ...(isOwn
416
+ ? []
417
+ : this.#catalog.reachedLibs.map((lib) => ({ root: `libs/${lib}`, typeRef: `srv.${lib}.${className}` }))),
418
+ ];
419
+ for (const { root, typeRef } of scopes) {
420
+ for (const folder of [ref, `_${ref}`]) {
421
+ const path = `${root}/lib/${folder}/${ref}.service.ts`;
422
+ if (await this.#sys.workspace.exists(path))
423
+ return { className, typeRef, content: await this.#sys.workspace.readFile(path) };
424
+ }
425
+ }
426
+ return null;
427
+ }
428
+
429
+ #parametersOf(service: ServiceSource, ref: string, method: string) {
430
+ const declared = classMethodParameters(service.content, service.className, method);
431
+ if (declared) return declared;
432
+ const names = modelNames(lowerlize(moduleComponentName(ref)));
433
+ const generated: Record<string, { name: string; optional: boolean }[]> = {
434
+ [names.getModel]: [{ name: "id", optional: false }],
435
+ [names.loadModel]: [{ name: "id", optional: true }],
436
+ [names.loadModelMany]: [{ name: "ids", optional: false }],
437
+ [names.createModel]: [{ name: "data", optional: false }],
438
+ [names.updateModel]: [
439
+ { name: "id", optional: false },
440
+ { name: "data", optional: false },
441
+ ],
442
+ [names.removeModel]: [{ name: "id", optional: false }],
443
+ };
444
+ return generated[method] ?? null;
445
+ }
446
+
447
+ #editDocument(content: string, source: MutationSource) {
448
+ if (hasClassMethod(content, source.modelClassName, source.chainMethod)) {
449
+ this.#report(
450
+ "add-mutation-chain-method-reused",
451
+ `${source.modelClassName}.${source.chainMethod}() already exists; the service chains it as is.`,
452
+ { severity: "warning" },
453
+ );
454
+ return null;
455
+ }
456
+ return this.#required(
457
+ insertClassMethod(content, source.modelClassName, source.chainMethodBlock),
458
+ `class ${source.modelClassName} in the document`,
459
+ );
460
+ }
461
+
462
+ #editService(content: string, source: MutationSource, calls: readonly ResolvedCall[]) {
463
+ const className = `${source.modelClassName}Service`;
464
+ let next: string | null = content;
465
+ for (const { injection } of calls) {
466
+ if (!next || !injection || hasSignalFactoryEntry(next, className, injection.field)) continue;
467
+ next = insertSignalFactoryEntry(
468
+ next,
469
+ className,
470
+ injection.field,
471
+ `${injection.field}: service<${injection.typeRef}>(),`,
472
+ {
473
+ mode: "destructure",
474
+ name: "service",
475
+ },
476
+ );
477
+ if (next && !/\* as srv from "\.\.\/srv";/.test(next))
478
+ next = ensureImportDeclaration(next, "../srv", "type * as srv");
479
+ }
480
+ next = next && insertClassMethod(next, className, source.serviceMethodBlock);
481
+ return this.#required(next, `${className} and its serve() factory`);
482
+ }
483
+
484
+ #editSignal(
485
+ content: string,
486
+ source: MutationSource,
487
+ guardSpecifiers: ReadonlyMap<string, string>,
488
+ acting: (MutationActingUser & { specifier: string }) | null,
489
+ ) {
490
+ let next = insertSignalFactoryEntry(
491
+ content,
492
+ `${source.modelClassName}Endpoint`,
493
+ source.spec.mutation,
494
+ source.signalEntry,
495
+ { mode: "destructure", name: "mutation" },
496
+ );
497
+ if (!next) return this.#required(next, `${source.modelClassName}Endpoint and its endpoint() factory`);
498
+ next = ensureNamedImport(next, "akanjs/base", "ID");
499
+ if (!/\* as cnst from "\.\.\/cnst";/.test(next)) next = ensureImportDeclaration(next, "../cnst", "* as cnst");
500
+ for (const [guard, specifier] of guardSpecifiers) next = ensureNamedImport(next, specifier, guard);
501
+ if (acting) next = ensureNamedImport(next, acting.specifier, acting.argName);
502
+ return next;
503
+ }
504
+
505
+ #editStore(content: string, source: MutationSource) {
506
+ const next = insertClassMethod(content, `${source.modelClassName}Store`, source.storeActionBlock);
507
+ return this.#required(next && ensureNamedImport(next, "../useClient", "fetch"), `${source.modelClassName}Store`);
508
+ }
509
+
510
+ #editDictionary(content: string, source: MutationSource) {
511
+ return this.#required(
512
+ insertDictionaryEndpointEntry(
513
+ content,
514
+ source.modelClassName,
515
+ source.spec.mutation,
516
+ source.dictionaryEntryOf(MutationScaffold.#modelKoOf(content)),
517
+ ),
518
+ `.endpoint<${source.modelClassName}Endpoint>() in the dictionary`,
519
+ );
520
+ }
521
+
522
+ static #modelKoOf(dictionary: string) {
523
+ return dictionary.match(/\.of\(\s*\(t\)\s*=>\s*t\(\s*\[\s*"[^"]*"\s*,\s*"([^"]+)"\s*\]/)?.[1];
524
+ }
525
+
526
+ #required(content: string | null, shape: string) {
527
+ if (!content)
528
+ this.#report("add-mutation-shape-unsupported", `Could not find ${shape}; refusing to write source.`, {
529
+ severity: "error",
530
+ });
531
+ return content;
532
+ }
533
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MutationSource } from "./mutationSource";
3
+
4
+ const sourceOf = (overrides: Partial<ConstructorParameters<typeof MutationSource>[0]> = {}) =>
5
+ new MutationSource({
6
+ module: "task",
7
+ mutation: "archiveTask",
8
+ guards: ["Admin"],
9
+ acting: null,
10
+ withCalls: [],
11
+ afterCalls: [],
12
+ ...overrides,
13
+ });
14
+
15
+ describe("MutationSource", () => {
16
+ test("drops the model from the chain method and keeps a name without it", () => {
17
+ expect(MutationSource.chainMethodOf("archiveTask", "Task")).toBe("archive");
18
+ expect(MutationSource.chainMethodOf("approveUserImages", "User")).toBe("approveImages");
19
+ expect(MutationSource.chainMethodOf("archive", "Task")).toBe("archive");
20
+ });
21
+
22
+ test("writes the past participle the transition comment names", () => {
23
+ expect(
24
+ ["archive", "pin", "start", "apply", "send", "hide", "open", "submit"].map(MutationSource.participleOf),
25
+ ).toEqual(["archived", "pinned", "started", "applied", "sent", "hidden", "opened", "submitted"]);
26
+ });
27
+
28
+ test("writes a chain method that mutates nothing until the author fills it", () => {
29
+ expect(sourceOf().chainMethodBlock).toBe(" // any → archived.\n archive() {\n return this;\n }");
30
+ });
31
+
32
+ test("loads, chains and saves under the bare verb without a transaction when nothing rides along", () => {
33
+ expect(sourceOf().serviceMethodBlock).toBe(
34
+ [
35
+ " async archive(taskId: string) {",
36
+ " const task = await this.getTask(taskId);",
37
+ " return await task.archive().save();",
38
+ " }",
39
+ ].join("\n"),
40
+ );
41
+ });
42
+
43
+ test("runs with-calls in the transaction and after-calls through afterCommit", () => {
44
+ const source = sourceOf({
45
+ withCalls: [{ ref: "board", method: "touchBoard", field: "boardService", args: ["task.id"] }],
46
+ afterCalls: [{ ref: "noti", method: "sendArchived", field: "notiService", args: ["task"] }],
47
+ });
48
+
49
+ expect(source.serviceMethodBlock).toBe(
50
+ [
51
+ " async archive(taskId: string) {",
52
+ " return await this.transaction(async () => {",
53
+ " const task = await this.getTask(taskId);",
54
+ " await task.archive().save();",
55
+ " await this.boardService.touchBoard(task.id);",
56
+ " await this.afterCommit(() => this.notiService.sendArchived(task));",
57
+ " return task;",
58
+ " });",
59
+ " }",
60
+ ].join("\n"),
61
+ );
62
+ });
63
+
64
+ test("hands the acting user to the service only through the guard's internal arg, and marks where it is checked", () => {
65
+ const source = sourceOf({
66
+ guards: ["User"],
67
+ acting: { argName: "Self", param: "self", value: "self.id", serviceParam: "selfId" },
68
+ });
69
+
70
+ expect(source.signalEntry).toBe(
71
+ [
72
+ "archiveTask: mutation(cnst.Task, { guards: [User] })",
73
+ ' .param("taskId", ID)',
74
+ " .with(Self)",
75
+ " .exec(async function (taskId, self) {",
76
+ " return await this.taskService.archive(taskId, self.id);",
77
+ " }),",
78
+ ].join("\n"),
79
+ );
80
+ expect(source.serviceMethodBlock).toBe(
81
+ [
82
+ " async archive(taskId: string, selfId: string) {",
83
+ " const task = await this.getTask(taskId);",
84
+ " // TODO: User admits any signed-in caller; refuse a selfId that may not archive this task.",
85
+ " return await task.archive().save();",
86
+ " }",
87
+ ].join("\n"),
88
+ );
89
+ expect(source.testTitles).toContain("archiveTask refuses a signed-in caller who may not archive the task");
90
+ expect(source.storeActionBlock).toBe(
91
+ [" async archiveTask(taskId: string) {", " this.setTask(await fetch.archiveTask(taskId));", " }"].join("\n"),
92
+ );
93
+ });
94
+
95
+ test("names the model's id in the dictionary with the model's own Korean label", () => {
96
+ expect(sourceOf().dictionaryEntryOf("할 일")).toContain(
97
+ 'taskId: t(["Task ID", "할 일 ID"]).desc(["Task ID", "할 일 ID"]),',
98
+ );
99
+ });
100
+
101
+ test("rate-limits a Public mutation and plans no refusal test for it", () => {
102
+ const source = sourceOf({ guards: ["Public"] });
103
+
104
+ expect(source.signalEntry).toContain("mutation(cnst.Task, { guards: [Public], rateLimit: { calls: 20 } })");
105
+ expect(source.testTitles).toEqual(["archiveTask leaves the task archived for a caller Public admits"]);
106
+ expect(sourceOf().testTitles).toContain("archiveTask refuses a caller Admin turns away");
107
+ });
108
+ });