@cosmicdrift/kumiko-framework 0.365.1 → 0.366.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 (103) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/access-control.md +32 -30
  3. package/agents/examples/anonymous-access-multitenant.md +14 -16
  4. package/agents/examples/anonymous-access.md +36 -43
  5. package/agents/examples/auth-foundation-providers.md +1 -1
  6. package/agents/examples/basic-entity.md +1 -1
  7. package/agents/examples/boot-check.md +3 -3
  8. package/agents/examples/chat-channels.md +1 -1
  9. package/agents/examples/cross-feature-events.md +7 -7
  10. package/agents/examples/currencies-global.md +3 -3
  11. package/agents/examples/currencies-per-tenant.md +2 -2
  12. package/agents/examples/custom-fields-basic.md +4 -3
  13. package/agents/examples/custom-handlers.md +40 -44
  14. package/agents/examples/dashboard-stats.md +6 -6
  15. package/agents/examples/delivery-notifications.md +9 -9
  16. package/agents/examples/designer-demo.md +2 -1
  17. package/agents/examples/embedded-entity-form.md +13 -13
  18. package/agents/examples/embedded.md +1 -1
  19. package/agents/examples/encrypted-tenant-config.md +1 -1
  20. package/agents/examples/error-contract.md +25 -25
  21. package/agents/examples/event-sourcing.md +96 -76
  22. package/agents/examples/feature-toggles.md +29 -21
  23. package/agents/examples/files-post-processing.md +22 -26
  24. package/agents/examples/idempotency.md +17 -19
  25. package/agents/examples/inbound-mail-triage.md +1 -1
  26. package/agents/examples/lane-routing.md +10 -12
  27. package/agents/examples/legal-pages.md +1 -1
  28. package/agents/examples/lifecycle-hooks.md +59 -39
  29. package/agents/examples/managed-config.md +9 -9
  30. package/agents/examples/managed-pages.md +1 -1
  31. package/agents/examples/notes-history-basic.md +3 -3
  32. package/agents/examples/pdf-audit-export.md +7 -7
  33. package/agents/examples/pipeline-basics.md +8 -8
  34. package/agents/examples/public-share-token.md +1 -1
  35. package/agents/examples/rate-limiting.md +7 -9
  36. package/agents/examples/record-detail-layout.md +26 -20
  37. package/agents/examples/reference-data.md +1 -1
  38. package/agents/examples/relations.md +2 -2
  39. package/agents/examples/screens-nav.md +2 -2
  40. package/agents/examples/search.md +17 -19
  41. package/agents/examples/security-baseline.md +1 -1
  42. package/agents/examples/state-machine.md +1 -1
  43. package/agents/examples/tags-basic.md +3 -3
  44. package/agents/examples/tenant-settings.md +2 -2
  45. package/agents/examples/timezones.md +12 -12
  46. package/agents/examples/user-data-rights.md +3 -3
  47. package/agents/examples/user-profile.md +1 -1
  48. package/agents/examples/webhook-step.md +1 -1
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/widgets.md +5 -0
  51. package/dist/api/server.js +12 -4
  52. package/dist/bun-db/query.js +16 -1
  53. package/dist/db/entity-table-meta.js +4 -4
  54. package/dist/engine/boot-validator/api-ext.js +2 -2
  55. package/dist/engine/boot-validator/config-deps.js +1 -1
  56. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  57. package/dist/engine/boot-validator/screens.js +1 -1
  58. package/dist/engine/build-app-schema.js +3 -6
  59. package/dist/engine/define-feature.js +5 -5
  60. package/dist/engine/entity-handlers.d.ts +2 -2
  61. package/dist/engine/entity-handlers.js +5 -5
  62. package/dist/engine/factories.d.ts +11 -11
  63. package/dist/engine/factories.js +11 -11
  64. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  65. package/dist/engine/feature-ast/render.js +4 -7
  66. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  67. package/dist/engine/feature-config-events-jobs.js +3 -0
  68. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  69. package/dist/engine/feature-entity-handlers.js +77 -100
  70. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  71. package/dist/engine/feature-ui-extensions.js +22 -14
  72. package/dist/engine/handler-field-references.d.ts +25 -0
  73. package/dist/engine/handler-field-references.js +80 -6
  74. package/dist/engine/index.d.ts +3 -3
  75. package/dist/engine/index.js +2 -2
  76. package/dist/engine/object-form.d.ts +2 -0
  77. package/dist/engine/object-form.js +13 -10
  78. package/dist/engine/registry-state.js +2 -2
  79. package/dist/engine/registry-validate.d.ts +1 -0
  80. package/dist/engine/registry-validate.js +21 -2
  81. package/dist/engine/registry.js +2 -1
  82. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  83. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  84. package/dist/engine/types/index.d.ts +1 -1
  85. package/dist/files/feature.js +1 -1
  86. package/dist/jobs/job-runner.js +16 -2
  87. package/dist/pipeline/dispatch-query.js +1 -1
  88. package/dist/pipeline/dispatch-shared.d.ts +2 -1
  89. package/dist/pipeline/dispatch-shared.js +3 -6
  90. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  91. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  92. package/dist/testing/index.d.ts +1 -0
  93. package/dist/testing/index.js +1 -0
  94. package/dist/testing/manual-clock.d.ts +14 -0
  95. package/dist/testing/manual-clock.js +12 -0
  96. package/dist/time/index.d.ts +1 -1
  97. package/dist/time/tz-context.d.ts +1 -1
  98. package/dist/time/tz-context.js +9 -4
  99. package/dist/upgrade-cli.js +13 -2
  100. package/package.json +4 -4
  101. package/src/changes.json +40 -0
  102. package/src/scripts/codemod/README.md +23 -0
  103. package/src/scripts/codemod/object-form.ts +709 -0
@@ -0,0 +1,709 @@
1
+ #!/usr/bin/env bun
2
+ // object-form — rewrites the positional registrar calls r.writeHandler / r.queryHandler /
3
+ // r.entity / r.requires / r.hook to their object form and registerEntityCrud(r, …) to
4
+ // r.crud(…). The positional overloads are gone; the object form is the only API.
5
+ //
6
+ // Usage:
7
+ // bun scripts/codemod/object-form.ts <targetDir...> [--check | --dry-run | --write] [--no-format]
8
+ // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1
9
+ // while any rewritable or manual site is left; --dry-run only reports.
10
+
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { dirname, join, relative, resolve } from "node:path";
13
+ import {
14
+ type CallExpression,
15
+ type ImportSpecifier,
16
+ Node,
17
+ type ParameterDeclaration,
18
+ Project,
19
+ type SourceFile,
20
+ SyntaxKind,
21
+ } from "ts-morph";
22
+ import { collectSourceFiles } from "./collect-source-files.js";
23
+
24
+ export const REGISTRAR_METHODS = [
25
+ "writeHandler",
26
+ "queryHandler",
27
+ "entity",
28
+ "requires",
29
+ "hook",
30
+ ] as const;
31
+ export type RegistrarMethod = (typeof REGISTRAR_METHODS)[number];
32
+ export type ObjectFormMethod = RegistrarMethod | "registerEntityCrud";
33
+
34
+ const CRUD_FUNCTION_NAME = "registerEntityCrud";
35
+ const REGISTRAR_TYPE_MARKER = "FeatureRegistrar";
36
+ const REGISTRAR_RECEIVER_NAMES: ReadonlySet<string> = new Set(["r", "registrar"]);
37
+ const FEATURE_FACTORY_NAME = "defineFeature";
38
+ const CHEAP_PREFILTER = /\b(writeHandler|queryHandler|entity|requires|hook|registerEntityCrud)\b/;
39
+
40
+ export interface ObjectFormRewrite {
41
+ readonly file: string;
42
+ readonly line: number;
43
+ readonly method: ObjectFormMethod;
44
+ }
45
+
46
+ export interface ObjectFormManualSite extends ObjectFormRewrite {
47
+ readonly why: string;
48
+ }
49
+
50
+ export interface ObjectFormResult {
51
+ readonly output: string;
52
+ readonly rewrites: ObjectFormRewrite[];
53
+ readonly manual: ObjectFormManualSite[];
54
+ }
55
+
56
+ export type ReceiverResolution = "registrar" | "other" | "unresolved";
57
+
58
+ function isRegistrarMethod(name: string): name is RegistrarMethod {
59
+ return REGISTRAR_METHODS.some((method) => method === name);
60
+ }
61
+
62
+ function isFeatureFactoryCallee(callee: Node): boolean {
63
+ if (Node.isIdentifier(callee)) return callee.getText() === FEATURE_FACTORY_NAME;
64
+ if (Node.isPropertyAccessExpression(callee)) return callee.getName() === FEATURE_FACTORY_NAME;
65
+ return false;
66
+ }
67
+
68
+ function isFeatureCallbackParameter(param: ParameterDeclaration): boolean {
69
+ const fn = param.getParent();
70
+ if (!Node.isArrowFunction(fn) && !Node.isFunctionExpression(fn)) return false;
71
+ if (fn.getParameters()[0] !== param) return false;
72
+ const call = fn.getParentIfKind(SyntaxKind.CallExpression);
73
+ return (
74
+ call !== undefined &&
75
+ call.getArguments()[1] === fn &&
76
+ isFeatureFactoryCallee(call.getExpression())
77
+ );
78
+ }
79
+
80
+ function resolveParameter(param: ParameterDeclaration): ReceiverResolution {
81
+ if (isFeatureCallbackParameter(param)) return "registrar";
82
+ const typeNode = param.getTypeNode();
83
+ if (typeNode === undefined) return "unresolved";
84
+ return typeNode.getText().includes(REGISTRAR_TYPE_MARKER) ? "registrar" : "other";
85
+ }
86
+
87
+ function declaredVariableIn(scope: Node, name: string): ReceiverResolution | undefined {
88
+ if (!Node.isBlock(scope) && !Node.isSourceFile(scope) && !Node.isModuleBlock(scope)) {
89
+ return undefined;
90
+ }
91
+ for (const statement of scope.getStatements()) {
92
+ if (!Node.isVariableStatement(statement)) continue;
93
+ for (const declaration of statement.getDeclarations()) {
94
+ if (declaration.getNameNode().getText() !== name) continue;
95
+ const typeText = declaration.getTypeNode()?.getText();
96
+ if (typeText === undefined) return "unresolved";
97
+ return typeText.includes(REGISTRAR_TYPE_MARKER) ? "registrar" : "other";
98
+ }
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ // Scope walk instead of a type checker: the nearest binding of the receiver's name decides.
104
+ export function resolveReceiver(receiver: Node): ReceiverResolution {
105
+ if (!Node.isIdentifier(receiver)) return "other";
106
+ const name = receiver.getText();
107
+ for (
108
+ let scope: Node | undefined = receiver.getParent();
109
+ scope !== undefined;
110
+ scope = scope.getParent()
111
+ ) {
112
+ if (Node.isParametered(scope)) {
113
+ const param = scope.getParameters().find((p) => p.getNameNode().getText() === name);
114
+ if (param !== undefined) return resolveParameter(param);
115
+ }
116
+ const variable = declaredVariableIn(scope, name);
117
+ if (variable !== undefined) return variable;
118
+ }
119
+ return "unresolved";
120
+ }
121
+
122
+ export function isFeatureRegistrarReceiver(receiver: Node): boolean {
123
+ return resolveReceiver(receiver) === "registrar";
124
+ }
125
+
126
+ function isUnresolvedRegistrarLookalike(receiver: Node): boolean {
127
+ return (
128
+ Node.isIdentifier(receiver) &&
129
+ REGISTRAR_RECEIVER_NAMES.has(receiver.getText()) &&
130
+ resolveReceiver(receiver) === "unresolved"
131
+ );
132
+ }
133
+
134
+ function isAssumedRegistrar(receiver: Node, assumeRegistrarNames: ReadonlySet<string>): boolean {
135
+ return (
136
+ Node.isIdentifier(receiver) &&
137
+ assumeRegistrarNames.has(receiver.getText()) &&
138
+ resolveReceiver(receiver) === "unresolved"
139
+ );
140
+ }
141
+
142
+ export function isPositionalShape(method: RegistrarMethod, args: readonly Node[]): boolean {
143
+ if (method === "requires") return args.some((arg) => !Node.isObjectLiteralExpression(arg));
144
+ // The object form takes exactly one argument, so a spread can only be a positional call.
145
+ if (args.some((arg) => Node.isSpreadElement(arg))) return true;
146
+ return args.length >= (method === "hook" ? 3 : 2);
147
+ }
148
+
149
+ interface Edit {
150
+ readonly start: number;
151
+ readonly end: number;
152
+ readonly render: () => string;
153
+ }
154
+
155
+ type Plan = { readonly edit: Edit } | { readonly why: string };
156
+
157
+ let sharedProject: Project | undefined;
158
+
159
+ function parserProject(): Project {
160
+ sharedProject ??= new Project({
161
+ useInMemoryFileSystem: true,
162
+ skipFileDependencyResolution: true,
163
+ });
164
+ return sharedProject;
165
+ }
166
+
167
+ function lastLineHasLineComment(piece: string): boolean {
168
+ return piece.slice(piece.lastIndexOf("\n") + 1).includes("//");
169
+ }
170
+
171
+ function terminated(piece: string): string {
172
+ if (lastLineHasLineComment(piece)) return `${piece}\n,`;
173
+ return piece.endsWith(",") ? piece : `${piece},`;
174
+ }
175
+
176
+ function composeObjectCall(pieces: readonly string[]): string {
177
+ const parts = pieces.filter((piece) => piece.length > 0);
178
+ const last = parts.at(-1) ?? "";
179
+ const body = [...parts.slice(0, -1).map(terminated), last].join(" ");
180
+ return `({ ${body}${lastLineHasLineComment(last) ? "\n" : " "}})`;
181
+ }
182
+
183
+ class SourceRewriter {
184
+ private readonly edits: Edit[] = [];
185
+ readonly rewrites: ObjectFormRewrite[] = [];
186
+ readonly manual: ObjectFormManualSite[] = [];
187
+
188
+ constructor(
189
+ private readonly text: string,
190
+ private readonly fileName: string,
191
+ ) {}
192
+
193
+ addEdit(edit: Edit): void {
194
+ this.edits.push(edit);
195
+ }
196
+
197
+ site(call: Node, method: ObjectFormMethod): ObjectFormRewrite {
198
+ return { file: this.fileName, line: call.getStartLineNumber(), method };
199
+ }
200
+
201
+ recordPlan(call: Node, method: ObjectFormMethod, plan: Plan): void {
202
+ if ("why" in plan) {
203
+ this.manual.push({ ...this.site(call, method), why: plan.why });
204
+ } else {
205
+ this.edits.push(plan.edit);
206
+ this.rewrites.push(this.site(call, method));
207
+ }
208
+ }
209
+
210
+ recordManual(call: Node, method: ObjectFormMethod, why: string): void {
211
+ this.manual.push({ ...this.site(call, method), why });
212
+ }
213
+
214
+ // Source between two offsets with every contained edit applied, so a rewritten call nested in
215
+ // an argument of another rewritten call is rendered once, inside the outer replacement.
216
+ textOf = (start: number, end: number): string => {
217
+ const ordered = [...this.edits].sort((a, b) => a.start - b.start || b.end - a.end);
218
+ let out = "";
219
+ let cursor = start;
220
+ for (const edit of ordered) {
221
+ if (edit.start < cursor || edit.end > end) continue;
222
+ out += this.text.slice(cursor, edit.start) + edit.render();
223
+ cursor = edit.end;
224
+ }
225
+ return out + this.text.slice(cursor, end);
226
+ };
227
+
228
+ commaEndAfter(position: number): number {
229
+ let i = position;
230
+ while (i < this.text.length) {
231
+ const ch = this.text[i];
232
+ if (ch === ",") return i + 1;
233
+ if (this.text.startsWith("/*", i)) {
234
+ const close = this.text.indexOf("*/", i + 2);
235
+ i = close === -1 ? this.text.length : close + 2;
236
+ } else if (this.text.startsWith("//", i)) {
237
+ const newline = this.text.indexOf("\n", i);
238
+ i = newline === -1 ? this.text.length : newline + 1;
239
+ } else {
240
+ i += 1;
241
+ }
242
+ }
243
+ return position;
244
+ }
245
+
246
+ // Leading comments stay attached: the slice starts right after the previous comma / the paren.
247
+ argSource(call: CallExpression, index: number): string {
248
+ const args = call.getArguments();
249
+ const arg = args[index];
250
+ if (arg === undefined) return "";
251
+ const previous = args[index - 1];
252
+ const start =
253
+ previous !== undefined
254
+ ? this.commaEndAfter(previous.getEnd())
255
+ : (call.getFirstChildByKind(SyntaxKind.OpenParenToken)?.getEnd() ?? arg.getStart());
256
+ return this.textOf(start, arg.getEnd()).trimStart();
257
+ }
258
+
259
+ fieldFrom(call: CallExpression, index: number, field: string): string {
260
+ const source = this.argSource(call, index);
261
+ return source === field ? field : `${field}: ${source}`;
262
+ }
263
+
264
+ // Property list of an object literal, comments inside it included, outer braces and trailing comma cut.
265
+ innerOf(literal: Node): string {
266
+ const inner = this.textOf(literal.getStart() + 1, literal.getEnd() - 1).trim();
267
+ return inner.endsWith(",") ? inner.slice(0, -1).trimEnd() : inner;
268
+ }
269
+
270
+ spreadOrInline(call: CallExpression, index: number): string {
271
+ const arg = call.getArguments()[index];
272
+ if (arg !== undefined && Node.isObjectLiteralExpression(arg)) return this.innerOf(arg);
273
+ return `...${this.argSource(call, index)}`;
274
+ }
275
+
276
+ // Replaces everything after the callee (type arguments and the argument list).
277
+ objectCallEdit(call: CallExpression, pieces: () => readonly string[]): Edit {
278
+ return {
279
+ start: call.getExpression().getEnd(),
280
+ end: call.getEnd(),
281
+ render: () => composeObjectCall(pieces()),
282
+ };
283
+ }
284
+ }
285
+
286
+ function hasSpreadArgument(args: readonly Node[]): boolean {
287
+ return args.some((arg) => Node.isSpreadElement(arg));
288
+ }
289
+
290
+ function planHandler(
291
+ rewriter: SourceRewriter,
292
+ call: CallExpression,
293
+ fieldNames: readonly [string, string, string],
294
+ ): Plan {
295
+ const args = call.getArguments();
296
+ if (hasSpreadArgument(args)) return { why: "spread argument — rewrite by hand" };
297
+ if (Node.isObjectLiteralExpression(args[0])) {
298
+ return { why: "first argument is an object literal — not the positional form" };
299
+ }
300
+ if (args.length < 3) return { why: "fewer than 3 arguments (name, schema, handler)" };
301
+ if (args.length > 4) return { why: "more than 4 arguments" };
302
+ const options = args[3];
303
+ return {
304
+ edit: rewriter.objectCallEdit(call, () => [
305
+ ...fieldNames.map((field, index) => rewriter.fieldFrom(call, index, field)),
306
+ options === undefined ? "" : rewriter.spreadOrInline(call, 3),
307
+ ]),
308
+ };
309
+ }
310
+
311
+ type BackingTableField = (() => string) | { readonly why: string };
312
+
313
+ function backingTableOf(rewriter: SourceRewriter, call: CallExpression): BackingTableField {
314
+ const options = call.getArguments()[2];
315
+ if (options === undefined) return () => "";
316
+ if (!Node.isObjectLiteralExpression(options)) {
317
+ return { why: "entity options argument is not an object literal" };
318
+ }
319
+ const only = options.getProperties()[0];
320
+ const invalid = { why: "entity options must contain exactly `table`" };
321
+ if (options.getProperties().length !== 1 || only === undefined) return invalid;
322
+ if (Node.isShorthandPropertyAssignment(only) && only.getName() === "table") {
323
+ return () => "backingTable: table";
324
+ }
325
+ if (Node.isPropertyAssignment(only) && only.getName() === "table") {
326
+ const initializer = only.getInitializer();
327
+ if (initializer === undefined) return { why: "entity `table` option has no value" };
328
+ return () => `backingTable: ${rewriter.textOf(initializer.getStart(), initializer.getEnd())}`;
329
+ }
330
+ return invalid;
331
+ }
332
+
333
+ function planEntity(rewriter: SourceRewriter, call: CallExpression): Plan {
334
+ const args = call.getArguments();
335
+ if (hasSpreadArgument(args)) return { why: "spread argument — rewrite by hand" };
336
+ if (Node.isObjectLiteralExpression(args[0])) {
337
+ return { why: "first argument is an object literal — not the positional form" };
338
+ }
339
+ if (args.length > 3) return { why: "more than 3 arguments" };
340
+ const backingTable = backingTableOf(rewriter, call);
341
+ if ("why" in backingTable) return backingTable;
342
+ return {
343
+ edit: rewriter.objectCallEdit(call, () => [
344
+ rewriter.fieldFrom(call, 0, "name"),
345
+ rewriter.spreadOrInline(call, 1),
346
+ backingTable(),
347
+ ]),
348
+ };
349
+ }
350
+
351
+ function planRequires(rewriter: SourceRewriter, call: CallExpression): Plan {
352
+ const args = call.getArguments();
353
+ if (args.some((arg) => Node.isObjectLiteralExpression(arg))) {
354
+ return { why: "mixes object literals with feature names" };
355
+ }
356
+ return {
357
+ edit: rewriter.objectCallEdit(call, () => [
358
+ `features: [${args.map((_, index) => rewriter.argSource(call, index)).join(", ")}]`,
359
+ ]),
360
+ };
361
+ }
362
+
363
+ function planHook(rewriter: SourceRewriter, call: CallExpression): Plan {
364
+ const args = call.getArguments();
365
+ if (hasSpreadArgument(args)) return { why: "spread argument — rewrite by hand" };
366
+ if (args.length > 4) return { why: "more than 4 arguments" };
367
+ return {
368
+ edit: rewriter.objectCallEdit(call, () => [
369
+ rewriter.fieldFrom(call, 0, "type"),
370
+ rewriter.fieldFrom(call, 1, "target"),
371
+ rewriter.fieldFrom(call, 2, "handler"),
372
+ args[3] === undefined ? "" : rewriter.spreadOrInline(call, 3),
373
+ ]),
374
+ };
375
+ }
376
+
377
+ function planRegistrarCall(
378
+ rewriter: SourceRewriter,
379
+ call: CallExpression,
380
+ method: RegistrarMethod,
381
+ ): Plan {
382
+ switch (method) {
383
+ case "writeHandler":
384
+ case "queryHandler":
385
+ return planHandler(rewriter, call, ["name", "schema", "handler"]);
386
+ case "entity":
387
+ return planEntity(rewriter, call);
388
+ case "requires":
389
+ return planRequires(rewriter, call);
390
+ case "hook":
391
+ return planHook(rewriter, call);
392
+ }
393
+ }
394
+
395
+ function crudImportsByLocalName(sourceFile: Node): Map<string, ImportSpecifier> {
396
+ const byLocalName = new Map<string, ImportSpecifier>();
397
+ for (const specifier of sourceFile.getDescendantsOfKind(SyntaxKind.ImportSpecifier)) {
398
+ if (specifier.getName() !== CRUD_FUNCTION_NAME) continue;
399
+ byLocalName.set(specifier.getAliasNode()?.getText() ?? specifier.getName(), specifier);
400
+ }
401
+ return byLocalName;
402
+ }
403
+
404
+ function planCrud(
405
+ rewriter: SourceRewriter,
406
+ call: CallExpression,
407
+ text: string,
408
+ assumeRegistrarNames: ReadonlySet<string>,
409
+ ): Plan {
410
+ const args = call.getArguments();
411
+ if (hasSpreadArgument(args)) return { why: "spread argument — rewrite by hand" };
412
+ if (args.length < 3) return { why: "fewer than 3 arguments (registrar, name, entity)" };
413
+ const receiver = args[0];
414
+ const second = args[1];
415
+ if (receiver === undefined || second === undefined) return { why: "missing arguments" };
416
+ if (!Node.isIdentifier(receiver)) {
417
+ return {
418
+ why: "first argument is not a plain identifier — rewrite to <registrar>.crud(…) by hand",
419
+ };
420
+ }
421
+ const resolution = resolveReceiver(receiver);
422
+ const looksLikeRegistrar =
423
+ resolution === "registrar" ||
424
+ (resolution === "unresolved" &&
425
+ (REGISTRAR_RECEIVER_NAMES.has(receiver.getText()) ||
426
+ assumeRegistrarNames.has(receiver.getText())));
427
+ if (!looksLikeRegistrar) {
428
+ return {
429
+ why: `first argument \`${receiver.getText()}\` is not a recognized FeatureRegistrar — rewrite to <registrar>.crud(…) by hand`,
430
+ };
431
+ }
432
+ const start = call.getStart();
433
+ const commaEnd = rewriter.commaEndAfter(receiver.getEnd());
434
+ const gap = text.slice(commaEnd, second.getStart());
435
+ const end = gap.trim() === "" ? second.getStart() : commaEnd;
436
+ return { edit: { start, end, render: () => `${receiver.getText()}.crud(` } };
437
+ }
438
+
439
+ function removeImportSpecifierEdit(specifier: ImportSpecifier): Edit {
440
+ const declaration = specifier.getImportDeclaration();
441
+ const named = declaration.getNamedImports();
442
+ if (
443
+ named.length === 1 &&
444
+ declaration.getDefaultImport() === undefined &&
445
+ declaration.getNamespaceImport() === undefined
446
+ ) {
447
+ const sourceText = declaration.getSourceFile().getFullText();
448
+ const start = declaration.getStart();
449
+ let end = declaration.getEnd();
450
+ if (sourceText[end] === "\n") end += 1;
451
+ return { start, end, render: () => "" };
452
+ }
453
+ const index = named.indexOf(specifier);
454
+ const next = named[index + 1];
455
+ const previous = named[index - 1];
456
+ if (next !== undefined) {
457
+ return { start: specifier.getStart(), end: next.getStart(), render: () => "" };
458
+ }
459
+ return {
460
+ start: previous?.getEnd() ?? specifier.getStart(),
461
+ end: specifier.getEnd(),
462
+ render: () => "",
463
+ };
464
+ }
465
+
466
+ function isOwnedByImport(node: Node): boolean {
467
+ return node.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
468
+ }
469
+
470
+ function visitRegistrarCall(
471
+ rewriter: SourceRewriter,
472
+ call: CallExpression,
473
+ assumeRegistrarNames: ReadonlySet<string>,
474
+ ): void {
475
+ const callee = call.getExpression();
476
+ const method = Node.isPropertyAccessExpression(callee) ? callee.getName() : undefined;
477
+ if (
478
+ !Node.isPropertyAccessExpression(callee) ||
479
+ method === undefined ||
480
+ !isRegistrarMethod(method) ||
481
+ !isPositionalShape(method, call.getArguments())
482
+ ) {
483
+ // skip: not a positional registrar call, nothing to rewrite
484
+ return;
485
+ }
486
+ const receiver = callee.getExpression();
487
+ if (isFeatureRegistrarReceiver(receiver) || isAssumedRegistrar(receiver, assumeRegistrarNames)) {
488
+ rewriter.recordPlan(call, method, planRegistrarCall(rewriter, call, method));
489
+ } else if (isUnresolvedRegistrarLookalike(receiver)) {
490
+ rewriter.recordManual(
491
+ call,
492
+ method,
493
+ `receiver \`${receiver.getText()}\` is not a resolvable FeatureRegistrar (no defineFeature callback parameter or FeatureRegistrar-typed binding) — rewrite by hand`,
494
+ );
495
+ }
496
+ }
497
+
498
+ function reportCrudImportLeftovers(
499
+ sourceFile: SourceFile,
500
+ rewriter: SourceRewriter,
501
+ crudImports: ReadonlyMap<string, ImportSpecifier>,
502
+ rewrittenCrudCallees: ReadonlySet<Node>,
503
+ ): void {
504
+ for (const [localName, specifier] of crudImports) {
505
+ const remainingUses = sourceFile
506
+ .getDescendantsOfKind(SyntaxKind.Identifier)
507
+ .filter(
508
+ (id) => id.getText() === localName && !isOwnedByImport(id) && !rewrittenCrudCallees.has(id),
509
+ );
510
+ if (remainingUses.length === 0) rewriter.addEdit(removeImportSpecifierEdit(specifier));
511
+ for (const use of remainingUses) {
512
+ if (use.getParentIfKind(SyntaxKind.CallExpression)?.getExpression() === use) continue;
513
+ rewriter.recordManual(
514
+ use,
515
+ CRUD_FUNCTION_NAME,
516
+ "referenced other than as a direct call — migrate by hand",
517
+ );
518
+ }
519
+ }
520
+ }
521
+
522
+ export interface ObjectFormSourceOptions {
523
+ /** Receivers with these names count as registrars when their binding cannot be resolved — for doc snippets that show `r.…` without a surrounding defineFeature. */
524
+ readonly assumeRegistrarNames?: ReadonlySet<string>;
525
+ }
526
+
527
+ export function transformObjectFormSource(
528
+ text: string,
529
+ fileName = "snippet.ts",
530
+ options: ObjectFormSourceOptions = {},
531
+ ): ObjectFormResult {
532
+ const assumeRegistrarNames = options.assumeRegistrarNames ?? new Set<string>();
533
+ if (!CHEAP_PREFILTER.test(text)) return { output: text, rewrites: [], manual: [] };
534
+
535
+ const project = parserProject();
536
+ const sourceFile = project.createSourceFile(fileName, text, { overwrite: true });
537
+ const rewriter = new SourceRewriter(text, fileName);
538
+ const crudImports = crudImportsByLocalName(sourceFile);
539
+ const rewrittenCrudCallees = new Set<Node>();
540
+
541
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
542
+ const callee = call.getExpression();
543
+ if (Node.isIdentifier(callee) && crudImports.has(callee.getText())) {
544
+ const plan = planCrud(rewriter, call, text, assumeRegistrarNames);
545
+ rewriter.recordPlan(call, CRUD_FUNCTION_NAME, plan);
546
+ if (!("why" in plan)) rewrittenCrudCallees.add(callee);
547
+ } else {
548
+ visitRegistrarCall(rewriter, call, assumeRegistrarNames);
549
+ }
550
+ }
551
+
552
+ reportCrudImportLeftovers(sourceFile, rewriter, crudImports, rewrittenCrudCallees);
553
+
554
+ const output = rewriter.textOf(0, text.length);
555
+ project.removeSourceFile(sourceFile);
556
+ return { output, rewrites: rewriter.rewrites, manual: rewriter.manual };
557
+ }
558
+
559
+ class CliError extends Error {}
560
+
561
+ type Mode = "write" | "dry-run" | "check";
562
+
563
+ interface CliOptions {
564
+ readonly mode: Mode;
565
+ readonly format: boolean;
566
+ readonly paths: string[];
567
+ }
568
+
569
+ function parseArgs(argv: readonly string[]): CliOptions {
570
+ let mode: Mode | undefined;
571
+ let format = true;
572
+ const paths: string[] = [];
573
+ const setMode = (next: Mode): void => {
574
+ if (mode !== undefined && mode !== next)
575
+ throw new CliError("--check, --dry-run and --write are mutually exclusive");
576
+ mode = next;
577
+ };
578
+ for (const arg of argv) {
579
+ if (arg === "--check") setMode("check");
580
+ else if (arg === "--dry-run") setMode("dry-run");
581
+ else if (arg === "--write") setMode("write");
582
+ else if (arg === "--no-format") format = false;
583
+ else if (arg.startsWith("--")) throw new CliError(`unknown flag: ${arg}`);
584
+ else paths.push(arg);
585
+ }
586
+ if (paths.length === 0) throw new CliError("at least one target directory is required");
587
+ return { mode: mode ?? "write", format, paths };
588
+ }
589
+
590
+ function isMigratableFile(name: string): boolean {
591
+ return /\.(ts|tsx)$/.test(name) && !name.endsWith(".d.ts");
592
+ }
593
+
594
+ function isFixturePath(file: string): boolean {
595
+ return file.includes("/__tests__/fixtures/");
596
+ }
597
+
598
+ function findBiomeBinary(startDir: string): string | undefined {
599
+ let dir = resolve(startDir);
600
+ for (;;) {
601
+ const candidate = join(dir, "node_modules", ".bin", "biome");
602
+ if (existsSync(candidate)) return candidate;
603
+ const parent = dirname(dir);
604
+ if (parent === dir) return undefined;
605
+ dir = parent;
606
+ }
607
+ }
608
+
609
+ const FORMAT_BATCH_SIZE = 50;
610
+
611
+ async function formatWithBiome(files: readonly string[], cwd: string): Promise<void> {
612
+ const biome = findBiomeBinary(cwd);
613
+ if (biome === undefined) {
614
+ console.log(
615
+ "[object-form] biome not found in node_modules/.bin — format the changed files yourself",
616
+ );
617
+ // skip: formatting is a convenience, the rewrite itself is already on disk
618
+ return;
619
+ }
620
+ for (let i = 0; i < files.length; i += FORMAT_BATCH_SIZE) {
621
+ const batch = files.slice(i, i + FORMAT_BATCH_SIZE);
622
+ const proc = Bun.spawn({
623
+ cmd: [biome, "format", "--write", "--no-errors-on-unmatched", ...batch],
624
+ cwd,
625
+ stdout: "pipe",
626
+ stderr: "pipe",
627
+ });
628
+ const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
629
+ if (exitCode !== 0) {
630
+ console.log(
631
+ `[object-form] biome format exited ${exitCode}; run it on the changed files yourself\n${stderr.trim()}`,
632
+ );
633
+ }
634
+ }
635
+ }
636
+
637
+ function displayPath(file: string): string {
638
+ const fromCwd = relative(process.cwd(), file);
639
+ return fromCwd.startsWith("..") ? file : fromCwd;
640
+ }
641
+
642
+ function countByMethod(rewrites: readonly ObjectFormRewrite[]): string {
643
+ const methods: readonly ObjectFormMethod[] = [...REGISTRAR_METHODS, CRUD_FUNCTION_NAME];
644
+ return methods
645
+ .map((method) => `${method} ${rewrites.filter((r) => r.method === method).length}`)
646
+ .join(", ");
647
+ }
648
+
649
+ function parseArgsOrExit(argv: readonly string[]): CliOptions {
650
+ try {
651
+ return parseArgs(argv);
652
+ } catch (err) {
653
+ if (err instanceof CliError) {
654
+ console.error(`[object-form] ${err.message}`);
655
+ process.exit(2);
656
+ }
657
+ throw err;
658
+ }
659
+ }
660
+
661
+ interface RunSummary {
662
+ readonly rewrites: ObjectFormRewrite[];
663
+ readonly manual: ObjectFormManualSite[];
664
+ readonly changedFiles: string[];
665
+ }
666
+
667
+ function transformFiles(files: readonly string[], mode: Mode): RunSummary {
668
+ const summary: RunSummary = { rewrites: [], manual: [], changedFiles: [] };
669
+ for (const file of files) {
670
+ const before = readFileSync(file, "utf8");
671
+ const result = transformObjectFormSource(before, file);
672
+ const shown = displayPath(file);
673
+ for (const site of result.rewrites)
674
+ console.log(`${shown}:${site.line} ${site.method} rewritten`);
675
+ for (const site of result.manual) {
676
+ console.log(`${shown}:${site.line} ${site.method} manual: ${site.why}`);
677
+ }
678
+ summary.rewrites.push(...result.rewrites);
679
+ summary.manual.push(...result.manual);
680
+ if (mode === "write" && result.output !== before) {
681
+ writeFileSync(file, result.output);
682
+ summary.changedFiles.push(file);
683
+ }
684
+ }
685
+ return summary;
686
+ }
687
+
688
+ async function main(): Promise<void> {
689
+ const options = parseArgsOrExit(process.argv.slice(2));
690
+ const files = collectSourceFiles(options.paths, { isMigratableFile }).filter(
691
+ (file) => !isFixturePath(file),
692
+ );
693
+ const { rewrites, manual, changedFiles } = transformFiles(files, options.mode);
694
+
695
+ console.log(
696
+ `\n[object-form] ${options.mode === "write" ? "rewritten" : "rewritable"}: ${rewrites.length} (${countByMethod(rewrites)}); manual: ${manual.length}`,
697
+ );
698
+
699
+ if (options.mode === "write" && options.format && changedFiles.length > 0) {
700
+ await formatWithBiome(changedFiles, resolve(options.paths[0] ?? "."));
701
+ }
702
+
703
+ const hasOpenSites = rewrites.length + manual.length > 0;
704
+ process.exit(options.mode === "check" && hasOpenSites ? 1 : 0);
705
+ }
706
+
707
+ if (import.meta.main) {
708
+ await main();
709
+ }