@beignet/cli 0.0.41 → 0.0.43

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 (100) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +96 -25
  3. package/dist/analysis/source-index.d.ts +1 -1
  4. package/dist/analysis/source-index.d.ts.map +1 -1
  5. package/dist/analysis/source-index.js +4 -2
  6. package/dist/analysis/source-index.js.map +1 -1
  7. package/dist/analysis/workspace.d.ts +3 -2
  8. package/dist/analysis/workspace.d.ts.map +1 -1
  9. package/dist/analysis/workspace.js +51 -39
  10. package/dist/analysis/workspace.js.map +1 -1
  11. package/dist/app-map.d.ts.map +1 -1
  12. package/dist/app-map.js +65 -4
  13. package/dist/app-map.js.map +1 -1
  14. package/dist/choices.d.ts +18 -0
  15. package/dist/choices.d.ts.map +1 -1
  16. package/dist/choices.js +35 -0
  17. package/dist/choices.js.map +1 -1
  18. package/dist/db.d.ts +18 -7
  19. package/dist/db.d.ts.map +1 -1
  20. package/dist/db.js +20 -7
  21. package/dist/db.js.map +1 -1
  22. package/dist/doctor-fixes.d.ts +64 -0
  23. package/dist/doctor-fixes.d.ts.map +1 -0
  24. package/dist/doctor-fixes.js +142 -0
  25. package/dist/doctor-fixes.js.map +1 -0
  26. package/dist/explain.d.ts +3 -1
  27. package/dist/explain.d.ts.map +1 -1
  28. package/dist/explain.js +136 -42
  29. package/dist/explain.js.map +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +92 -25
  32. package/dist/index.js.map +1 -1
  33. package/dist/inspect.d.ts +38 -9
  34. package/dist/inspect.d.ts.map +1 -1
  35. package/dist/inspect.js +385 -251
  36. package/dist/inspect.js.map +1 -1
  37. package/dist/lib.d.ts +6 -2
  38. package/dist/lib.d.ts.map +1 -1
  39. package/dist/lib.js +3 -2
  40. package/dist/lib.js.map +1 -1
  41. package/dist/make/inbox.js +4 -4
  42. package/dist/make/payments.js +6 -6
  43. package/dist/make/shared.js +3 -3
  44. package/dist/make/shared.js.map +1 -1
  45. package/dist/make/tenancy.d.ts.map +1 -1
  46. package/dist/make/tenancy.js +12 -14
  47. package/dist/make/tenancy.js.map +1 -1
  48. package/dist/make.d.ts.map +1 -1
  49. package/dist/make.js +33 -23
  50. package/dist/make.js.map +1 -1
  51. package/dist/mcp.d.ts.map +1 -1
  52. package/dist/mcp.js +121 -13
  53. package/dist/mcp.js.map +1 -1
  54. package/dist/registry-edits.d.ts +7 -1
  55. package/dist/registry-edits.d.ts.map +1 -1
  56. package/dist/registry-edits.js +61 -43
  57. package/dist/registry-edits.js.map +1 -1
  58. package/dist/templates/agents.d.ts.map +1 -1
  59. package/dist/templates/agents.js +34 -11
  60. package/dist/templates/agents.js.map +1 -1
  61. package/dist/templates/base.d.ts.map +1 -1
  62. package/dist/templates/base.js +5 -5
  63. package/dist/templates/base.js.map +1 -1
  64. package/dist/templates/db/mysql.js +3 -3
  65. package/dist/templates/db/postgres.js +3 -3
  66. package/dist/templates/db/sqlite.js +3 -3
  67. package/dist/templates/server.js +4 -4
  68. package/dist/templates/server.js.map +1 -1
  69. package/dist/templates/shared.d.ts +2 -1
  70. package/dist/templates/shared.d.ts.map +1 -1
  71. package/dist/templates/shared.js +7 -4
  72. package/dist/templates/shared.js.map +1 -1
  73. package/dist/templates/todos.js +2 -2
  74. package/package.json +3 -2
  75. package/skills/app-structure/SKILL.md +43 -10
  76. package/src/analysis/source-index.ts +12 -3
  77. package/src/analysis/workspace.ts +94 -52
  78. package/src/app-map.ts +82 -4
  79. package/src/choices.ts +57 -0
  80. package/src/db.ts +45 -15
  81. package/src/doctor-fixes.ts +252 -0
  82. package/src/explain.ts +151 -43
  83. package/src/index.ts +130 -35
  84. package/src/inspect.ts +537 -295
  85. package/src/lib.ts +28 -1
  86. package/src/make/inbox.ts +4 -4
  87. package/src/make/payments.ts +6 -6
  88. package/src/make/shared.ts +3 -3
  89. package/src/make/tenancy.ts +12 -14
  90. package/src/make.ts +36 -24
  91. package/src/mcp.ts +187 -13
  92. package/src/registry-edits.ts +81 -39
  93. package/src/templates/agents.ts +34 -11
  94. package/src/templates/base.ts +5 -4
  95. package/src/templates/db/mysql.ts +3 -3
  96. package/src/templates/db/postgres.ts +3 -3
  97. package/src/templates/db/sqlite.ts +3 -3
  98. package/src/templates/server.ts +4 -4
  99. package/src/templates/shared.ts +14 -6
  100. package/src/templates/todos.ts +2 -2
package/src/inspect.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseProviderPackageMetadata } from "@beignet/core/providers";
4
4
  import { createPainter } from "./ansi.js";
@@ -15,6 +15,18 @@ import {
15
15
  type ResolvedBeignetConfig,
16
16
  resolveConfig,
17
17
  } from "./config.js";
18
+ import {
19
+ applyPlannedDoctorFixes,
20
+ createDoctorFixPlan,
21
+ type DoctorFixApplyResult,
22
+ type DoctorFixOperationId,
23
+ type DoctorFixPlan,
24
+ type InspectFix,
25
+ type PlannedDoctorFixOperation,
26
+ type PlannedDoctorFixPlan,
27
+ planDoctorFixFileChange,
28
+ publicDoctorFixPlan,
29
+ } from "./doctor-fixes.js";
18
30
  import {
19
31
  formatGithubAnnotation,
20
32
  type GithubAnnotationSeverity,
@@ -47,6 +59,7 @@ import {
47
59
  appendToNamedArray,
48
60
  appendToOutboxRegistryArray,
49
61
  arrayInitializerInfo,
62
+ codeCharacterIndexes,
50
63
  identifiersFromArrayExpression,
51
64
  insertAfterImports,
52
65
  matchingDelimiterIndex,
@@ -55,6 +68,7 @@ import {
55
68
  import {
56
69
  currentGeneratedPackageScripts,
57
70
  externalVersions,
71
+ isLegacyGeneratedPackageScript,
58
72
  legacyGeneratedPackageScripts,
59
73
  } from "./templates/shared.js";
60
74
  import { testSupportTemplateFiles } from "./templates/testing.js";
@@ -117,16 +131,14 @@ export type InspectDiagnostic = {
117
131
  message: string;
118
132
  file?: string;
119
133
  contract?: string;
134
+ /** Stable declaration identity for diagnostics about a named app concept. */
135
+ subject?: {
136
+ file: string;
137
+ exportName: string;
138
+ };
120
139
  };
121
140
 
122
- /**
123
- * Automatic doctor fix that was applied.
124
- */
125
- export type InspectFix = {
126
- code: string;
127
- message: string;
128
- file: string;
129
- };
141
+ export type { InspectFix } from "./doctor-fixes.js";
130
142
 
131
143
  /**
132
144
  * Detected app convention and missing convention files.
@@ -157,6 +169,18 @@ export type InspectAppResult = {
157
169
  fixes: InspectFix[];
158
170
  };
159
171
 
172
+ /** Read-only doctor fix plan paired with the diagnostics it was derived from. */
173
+ export type DoctorFixPlanResult = DoctorFixPlan & {
174
+ convention: InspectConvention;
175
+ diagnostics: InspectDiagnostic[];
176
+ };
177
+
178
+ /** Post-apply inspection paired with the guarded plan metadata that ran. */
179
+ export type DoctorFixInspectionResult = InspectAppResult & {
180
+ planId: string;
181
+ operationIds: DoctorFixOperationId[];
182
+ };
183
+
160
184
  type RouteExport = {
161
185
  method: HttpMethod;
162
186
  handlerFile: string;
@@ -235,9 +259,66 @@ export async function inspectApp(
235
259
  };
236
260
  }
237
261
 
262
+ /** Options for guarded all-or-selected doctor fix application. */
263
+ export type ApplyDoctorFixPlanOptions = InspectAppOptions & {
264
+ planId: string;
265
+ fixIds?: readonly DoctorFixOperationId[];
266
+ };
267
+
268
+ /** Plan every currently available low-risk doctor repair without writing. */
269
+ export async function planDoctorFixes(
270
+ options: InspectAppOptions = {},
271
+ ): Promise<DoctorFixPlanResult> {
272
+ const plan = publicDoctorFixPlan(await buildDoctorFixPlan(options));
273
+ const inspected = await inspectApp(options);
274
+ return {
275
+ ...plan,
276
+ convention: inspected.convention,
277
+ diagnostics: inspected.diagnostics,
278
+ };
279
+ }
280
+
281
+ /** Apply a current doctor fix plan after verifying its plan ID. */
282
+ export async function applyDoctorFixPlan(
283
+ options: ApplyDoctorFixPlanOptions,
284
+ ): Promise<DoctorFixApplyResult> {
285
+ const plan = await buildDoctorFixPlan(options);
286
+ if (plan.planId !== options.planId) {
287
+ throw new Error(
288
+ "Doctor fix plan is stale because the available repairs changed. No files were changed; run beignet doctor --fix --dry-run again.",
289
+ );
290
+ }
291
+ return applyPlannedDoctorFixes(plan, options.fixIds);
292
+ }
293
+
238
294
  export async function applyDoctorFixes(
239
295
  options: InspectAppOptions = {},
240
296
  ): Promise<InspectFix[]> {
297
+ return (await applyDoctorFixesWithResult(options)).fixes;
298
+ }
299
+
300
+ export async function applyDoctorFixesWithResult(
301
+ options: InspectAppOptions = {},
302
+ ): Promise<DoctorFixApplyResult> {
303
+ const plan = await buildDoctorFixPlan(options);
304
+ return applyPlannedDoctorFixes(plan);
305
+ }
306
+
307
+ export function createDoctorFixInspectionResult(
308
+ inspected: InspectAppResult,
309
+ applied: DoctorFixApplyResult,
310
+ ): DoctorFixInspectionResult {
311
+ return {
312
+ ...inspected,
313
+ planId: applied.planId,
314
+ operationIds: applied.operationIds,
315
+ fixes: applied.fixes,
316
+ };
317
+ }
318
+
319
+ async function buildDoctorFixPlan(
320
+ options: InspectAppOptions = {},
321
+ ): Promise<PlannedDoctorFixPlan> {
241
322
  const targetDir = path.resolve(options.cwd ?? process.cwd());
242
323
  await assertDirectory(targetDir);
243
324
 
@@ -253,44 +334,63 @@ export async function applyDoctorFixes(
253
334
  config,
254
335
  contracts,
255
336
  );
256
- const fixes: InspectFix[] = [];
337
+ const operations: PlannedDoctorFixOperation[] = [];
257
338
 
258
- const packageFix = await fixPackageScripts(targetDir, files, convention);
259
- if (packageFix) fixes.push(packageFix);
339
+ const packageFix = await planPackageScripts(targetDir, files, convention);
340
+ if (packageFix) operations.push(packageFix);
260
341
 
261
- const routeGroupFix = await fixUnregisteredRouteGroups(
342
+ const routeGroupFix = await planUnregisteredRouteGroups(
262
343
  targetDir,
263
344
  files,
264
345
  config,
265
346
  );
266
- if (routeGroupFix) fixes.push(routeGroupFix);
347
+ if (routeGroupFix) operations.push(routeGroupFix);
267
348
 
268
349
  const drift = await workflowRegistrationDrift(targetDir, files, config);
269
350
 
270
- const scheduleFix = await fixUnregisteredSchedules(
351
+ if (config.paths.schedules === config.paths.tasks) {
352
+ const workflowFix = await planCoLocatedScheduleAndTaskRegistries(
353
+ targetDir,
354
+ files,
355
+ drift,
356
+ config,
357
+ );
358
+ if (workflowFix) operations.push(workflowFix);
359
+ } else {
360
+ const scheduleFix = await planUnregisteredSchedules(
361
+ targetDir,
362
+ files,
363
+ drift,
364
+ config,
365
+ );
366
+ if (scheduleFix) operations.push(scheduleFix);
367
+
368
+ const taskFix = await planUnregisteredTasks(
369
+ targetDir,
370
+ files,
371
+ drift,
372
+ config,
373
+ );
374
+ if (taskFix) operations.push(taskFix);
375
+ }
376
+
377
+ const outboxFix = await planUnregisteredOutboxEntries(
271
378
  targetDir,
272
379
  files,
273
380
  drift,
274
381
  config,
275
382
  );
276
- if (scheduleFix) fixes.push(scheduleFix);
277
-
278
- const taskFix = await fixUnregisteredTasks(targetDir, files, drift, config);
279
- if (taskFix) fixes.push(taskFix);
383
+ if (outboxFix) operations.push(outboxFix);
280
384
 
281
- fixes.push(
282
- ...(await fixUnregisteredOutboxEntries(targetDir, files, drift, config)),
283
- );
284
-
285
- const listenerFix = await fixUnregisteredListeners(
385
+ const listenerFix = await planUnregisteredListeners(
286
386
  targetDir,
287
387
  files,
288
388
  drift,
289
389
  config,
290
390
  );
291
- if (listenerFix) fixes.push(listenerFix);
391
+ if (listenerFix) operations.push(listenerFix);
292
392
 
293
- const openApiFix = await fixDirectOpenApiArrayDrift(
393
+ const openApiFix = await planDirectOpenApiArrayDrift(
294
394
  targetDir,
295
395
  files,
296
396
  config,
@@ -298,9 +398,13 @@ export async function applyDoctorFixes(
298
398
  contracts,
299
399
  matchedRoutes,
300
400
  );
301
- if (openApiFix) fixes.push(openApiFix);
401
+ if (openApiFix) operations.push(openApiFix);
302
402
 
303
- return fixes;
403
+ return createDoctorFixPlan({
404
+ targetDir,
405
+ strict: Boolean(options.strict),
406
+ operations,
407
+ });
304
408
  }
305
409
 
306
410
  /**
@@ -329,12 +433,97 @@ export function formatRoutes(result: InspectAppResult): string {
329
433
  ]);
330
434
  }
331
435
 
436
+ /**
437
+ * Format a read-only doctor fix plan with exact patches and apply commands.
438
+ */
439
+ export function formatDoctorFixPlan(
440
+ plan: DoctorFixPlanResult,
441
+ options: { color?: boolean } = {},
442
+ ): string {
443
+ const paint = createPainter(options.color);
444
+ const lines: string[] = [];
445
+ if (plan.operations.length === 0) {
446
+ lines.push(`No applicable Beignet fixes found in ${plan.targetDir}.`);
447
+ } else {
448
+ const strictFlag = plan.strict ? " --strict" : "";
449
+ lines.push(
450
+ `Planned ${plan.operations.length} Beignet fix operation${plan.operations.length === 1 ? "" : "s"} in ${plan.targetDir}:`,
451
+ "",
452
+ );
453
+ for (const operation of plan.operations) {
454
+ lines.push(
455
+ `${paint("PLAN", "cyan")} ${operation.id}`,
456
+ ...operation.fixes.map(
457
+ (fix) => ` ${fix.code} ${fix.file}\n ${fix.message}`,
458
+ ),
459
+ ...operation.changes.flatMap((change) => [
460
+ ` ${change.kind === "create" ? "Create" : "Update"} ${change.file}`,
461
+ change.patch.trimEnd(),
462
+ ]),
463
+ "",
464
+ );
465
+ }
466
+
467
+ lines.push(
468
+ `Plan ID: ${plan.planId}`,
469
+ "",
470
+ "Apply the complete plan:",
471
+ ` beignet doctor --fix${strictFlag} --plan ${plan.planId}`,
472
+ "",
473
+ "Apply selected operations:",
474
+ ` beignet doctor --fix${strictFlag} --plan ${plan.planId} --only ${plan.operations.map((operation) => operation.id).join(",")}`,
475
+ );
476
+ }
477
+
478
+ lines.push(
479
+ "",
480
+ formatDoctorResult(
481
+ {
482
+ targetDir: plan.targetDir,
483
+ strict: plan.strict,
484
+ diagnostics: plan.diagnostics,
485
+ fixes: [],
486
+ },
487
+ options,
488
+ ),
489
+ );
490
+ return lines.join("\n");
491
+ }
492
+
493
+ /**
494
+ * Format planned fixes as GitHub Actions notice annotations.
495
+ */
496
+ export function formatDoctorFixPlanGithub(plan: DoctorFixPlanResult): string {
497
+ return [
498
+ ...plan.operations.map((operation) =>
499
+ formatGithubAnnotation({
500
+ severity: "notice",
501
+ file: operation.changes[0]?.file,
502
+ message: `${operation.id}: ${operation.fixes.map((fix) => fix.message).join(" ")} Plan ${plan.planId}.`,
503
+ }),
504
+ ),
505
+ ...formatDoctorDiagnosticsGithub(plan.diagnostics),
506
+ ].join("\n");
507
+ }
508
+
509
+ type DoctorFormatResult = Pick<
510
+ InspectAppResult,
511
+ "targetDir" | "strict" | "diagnostics" | "fixes"
512
+ >;
513
+
332
514
  /**
333
515
  * Format doctor diagnostics and applied fixes for CLI output.
334
516
  */
335
517
  export function formatDoctor(
336
518
  result: InspectAppResult,
337
519
  options: { color?: boolean } = {},
520
+ ): string {
521
+ return formatDoctorResult(result, options);
522
+ }
523
+
524
+ function formatDoctorResult(
525
+ result: DoctorFormatResult,
526
+ options: { color?: boolean } = {},
338
527
  ): string {
339
528
  const paint = createPainter(options.color);
340
529
  const fixLines =
@@ -456,7 +645,7 @@ function productionHardeningChecklist(
456
645
  ];
457
646
  }
458
647
 
459
- function formatDoctorFooter(result: InspectAppResult): string {
648
+ function formatDoctorFooter(result: DoctorFormatResult): string {
460
649
  const count = (severity: InspectDiagnostic["severity"]) =>
461
650
  result.diagnostics.filter((diagnostic) => diagnostic.severity === severity)
462
651
  .length;
@@ -469,15 +658,19 @@ function formatDoctorFooter(result: InspectAppResult): string {
469
658
  * Format doctor diagnostics as GitHub Actions annotations.
470
659
  */
471
660
  export function formatDoctorGithub(result: InspectAppResult): string {
472
- return result.diagnostics
473
- .map((diagnostic) =>
474
- formatGithubAnnotation({
475
- severity: githubAnnotationSeverity(diagnostic.severity),
476
- file: diagnostic.file,
477
- message: `${diagnostic.code}: ${diagnostic.message}`,
478
- }),
479
- )
480
- .join("\n");
661
+ return formatDoctorDiagnosticsGithub(result.diagnostics).join("\n");
662
+ }
663
+
664
+ function formatDoctorDiagnosticsGithub(
665
+ diagnostics: InspectDiagnostic[],
666
+ ): string[] {
667
+ return diagnostics.map((diagnostic) =>
668
+ formatGithubAnnotation({
669
+ severity: githubAnnotationSeverity(diagnostic.severity),
670
+ file: diagnostic.file,
671
+ message: `${diagnostic.code}: ${diagnostic.message}`,
672
+ }),
673
+ );
481
674
  }
482
675
 
483
676
  function githubAnnotationSeverity(
@@ -878,73 +1071,19 @@ function balancedObjectSource(
878
1071
  startIndex: number,
879
1072
  ): string | undefined {
880
1073
  if (source[startIndex] !== "{") return undefined;
881
-
882
- let depth = 0;
883
- let quote: '"' | "'" | "`" | undefined;
884
- let escaped = false;
885
-
886
- for (let index = startIndex; index < source.length; index += 1) {
887
- const char = source[index];
888
-
889
- if (quote) {
890
- if (escaped) {
891
- escaped = false;
892
- continue;
893
- }
894
- if (char === "\\") {
895
- escaped = true;
896
- continue;
897
- }
898
- if (char === quote) quote = undefined;
899
- continue;
900
- }
901
-
902
- if (char === '"' || char === "'" || char === "`") {
903
- quote = char;
904
- continue;
905
- }
906
-
907
- if (char === "{") {
908
- depth += 1;
909
- continue;
910
- }
911
-
912
- if (char === "}") {
913
- depth -= 1;
914
- if (depth === 0) return source.slice(startIndex, index + 1);
915
- }
916
- }
917
-
918
- return undefined;
1074
+ const endIndex = matchingDelimiterIndex(source, startIndex, "{", "}");
1075
+ return endIndex === -1 ? undefined : source.slice(startIndex, endIndex + 1);
919
1076
  }
920
1077
 
921
1078
  function topLevelObjectKeys(body: string): string[] {
922
1079
  const keys: string[] = [];
923
1080
  let depth = 0;
924
- let quote: '"' | "'" | "`" | undefined;
925
- let escaped = false;
1081
+ let skipUntil = 0;
926
1082
 
927
- for (let index = 0; index < body.length; index += 1) {
1083
+ for (const index of codeCharacterIndexes(body)) {
1084
+ if (index < skipUntil) continue;
928
1085
  const char = body[index];
929
1086
 
930
- if (quote) {
931
- if (escaped) {
932
- escaped = false;
933
- continue;
934
- }
935
- if (char === "\\") {
936
- escaped = true;
937
- continue;
938
- }
939
- if (char === quote) quote = undefined;
940
- continue;
941
- }
942
-
943
- if (char === '"' || char === "'" || char === "`") {
944
- quote = char;
945
- continue;
946
- }
947
-
948
1087
  if (char === "{" || char === "[" || char === "(") {
949
1088
  depth += 1;
950
1089
  continue;
@@ -963,8 +1102,7 @@ function topLevelObjectKeys(body: string): string[] {
963
1102
  const name = identifier[0];
964
1103
  const next = body.slice(index + name.length).match(/^\s*:/);
965
1104
  if (next) keys.push(name);
966
-
967
- index += name.length - 1;
1105
+ skipUntil = index + name.length;
968
1106
  }
969
1107
 
970
1108
  return keys;
@@ -3831,6 +3969,7 @@ async function inspectWorkflowRegistrationDrift(
3831
3969
  severity: "error",
3832
3970
  code,
3833
3971
  file: centralExists ? centralFile : registry.indexFile,
3972
+ subject: workflowArtifactSubject(registry, member),
3834
3973
  message: centralExists
3835
3974
  ? `${workflowArtifactOrigin(registry, member)}, but it is not registered in ${registrationTarget} in ${centralFile}, so the ${noun} never runs. Import ${registry.registryName} and spread it into the ${kind} list.`
3836
3975
  : `${workflowArtifactOrigin(registry, member)}, but ${centralFile} does not exist, so the ${noun} never runs. Register ${registry.registryName} in ${centralFile} (or run \`${makeCommand}\` to create it).`,
@@ -3846,6 +3985,7 @@ async function inspectWorkflowRegistrationDrift(
3846
3985
  severity: "warning",
3847
3986
  code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
3848
3987
  file: config.paths.outbox,
3988
+ subject: workflowArtifactSubject(registry, member),
3849
3989
  message: `${workflowArtifactOrigin(registry, member)} and listeners react to it, but it is not registered in the events list of defineOutboxRegistry({...}) in ${config.paths.outbox}, so outbox drain cannot deliver it. Import ${registry.registryName} and spread it into the events list.`,
3850
3990
  });
3851
3991
  }
@@ -3857,6 +3997,7 @@ async function inspectWorkflowRegistrationDrift(
3857
3997
  severity: "warning",
3858
3998
  code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
3859
3999
  file: config.paths.outbox,
4000
+ subject: workflowArtifactSubject(registry, member),
3860
4001
  message: `${workflowArtifactOrigin(registry, member)}, but it is not registered in the jobs list of defineOutboxRegistry({...}) in ${config.paths.outbox}, so outbox drain cannot deliver it. Import ${registry.registryName} and spread it into the jobs list.`,
3861
4002
  });
3862
4003
  }
@@ -3878,6 +4019,7 @@ async function inspectWorkflowRegistrationDrift(
3878
4019
  drift.listeners.wiringFile ??
3879
4020
  drift.listeners.eventBusFile ??
3880
4021
  registry.indexFile,
4022
+ subject: workflowArtifactSubject(registry, member),
3881
4023
  message: `${workflowArtifactOrigin(registry, member)}, but no registerListeners(...) call references it, so the listener never receives events. Register ${registry.registryName} with registerListeners(...) in ${listenerTarget}.`,
3882
4024
  });
3883
4025
  }
@@ -3983,6 +4125,16 @@ function workflowArtifactOrigin(
3983
4125
  return `${memberFile} declares ${member} (exported through ${registry.registryName} in ${registry.indexFile})`;
3984
4126
  }
3985
4127
 
4128
+ function workflowArtifactSubject(
4129
+ registry: FeatureWorkflowRegistry,
4130
+ member: string,
4131
+ ): NonNullable<InspectDiagnostic["subject"]> {
4132
+ return {
4133
+ file: registry.memberFiles.get(member) ?? registry.indexFile,
4134
+ exportName: member,
4135
+ };
4136
+ }
4137
+
3986
4138
  async function workflowRegistrationDrift(
3987
4139
  targetDir: string,
3988
4140
  files: string[],
@@ -5464,26 +5616,9 @@ function splitTopLevelArguments(source: string): string[] {
5464
5616
  const args: string[] = [];
5465
5617
  let start = 0;
5466
5618
  let depth = 0;
5467
- let inString: string | undefined;
5468
- let escaped = false;
5469
5619
 
5470
- for (let index = 0; index < source.length; index++) {
5620
+ for (const index of codeCharacterIndexes(source)) {
5471
5621
  const char = source[index];
5472
- if (inString) {
5473
- if (escaped) {
5474
- escaped = false;
5475
- } else if (char === "\\") {
5476
- escaped = true;
5477
- } else if (char === inString) {
5478
- inString = undefined;
5479
- }
5480
- continue;
5481
- }
5482
-
5483
- if (char === '"' || char === "'" || char === "`") {
5484
- inString = char;
5485
- continue;
5486
- }
5487
5622
  if (char === "(" || char === "{" || char === "[") {
5488
5623
  depth += 1;
5489
5624
  continue;
@@ -6020,8 +6155,7 @@ async function inspectPackageScripts(
6020
6155
  severity: "warning",
6021
6156
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
6022
6157
  file: "package.json",
6023
- message:
6024
- 'package.json does not define a test script. Add "test": "tsx lib/beignet-test-runner.ts" or run beignet doctor --fix.',
6158
+ message: `package.json does not define a test script. Add "test": "${currentGeneratedPackageScripts.test}" or run beignet doctor --fix.`,
6025
6159
  });
6026
6160
  }
6027
6161
 
@@ -6033,7 +6167,7 @@ async function inspectPackageScripts(
6033
6167
  severity: "warning",
6034
6168
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
6035
6169
  file: "package.json",
6036
- message: `package.json still uses legacy generated Bun scripts for ${legacyScripts.join(", ")}. Run beignet doctor --fix to migrate them to package-manager-neutral tsx scripts.`,
6170
+ message: `package.json still uses legacy generated scripts for ${legacyScripts.join(", ")}. Run beignet doctor --fix to migrate them to the current package-manager-neutral scripts.`,
6037
6171
  });
6038
6172
  }
6039
6173
 
@@ -6067,11 +6201,11 @@ async function inspectPackageScripts(
6067
6201
  return diagnostics;
6068
6202
  }
6069
6203
 
6070
- async function fixPackageScripts(
6204
+ async function planPackageScripts(
6071
6205
  targetDir: string,
6072
6206
  files: string[],
6073
6207
  convention: InspectConvention,
6074
- ): Promise<InspectFix | undefined> {
6208
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6075
6209
  if (!convention.resourceGenerator || !files.includes("package.json")) {
6076
6210
  return undefined;
6077
6211
  }
@@ -6097,10 +6231,8 @@ async function fixPackageScripts(
6097
6231
  }
6098
6232
 
6099
6233
  if (!hasDirectBunTestImports) {
6100
- for (const [name, legacyCommand] of Object.entries(
6101
- legacyGeneratedPackageScripts,
6102
- )) {
6103
- if (packageJson.scripts[name] === legacyCommand) {
6234
+ for (const name of Object.keys(legacyGeneratedPackageScripts)) {
6235
+ if (isLegacyGeneratedPackageScript(name, packageJson.scripts[name])) {
6104
6236
  packageJson.scripts[name] = currentGeneratedPackageScripts[name];
6105
6237
  if (!updatedScripts.includes(name)) updatedScripts.push(name);
6106
6238
  }
@@ -6118,29 +6250,25 @@ async function fixPackageScripts(
6118
6250
  }
6119
6251
 
6120
6252
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
6121
- const createdSupportFiles = await createMissingTestSupportFiles(
6122
- targetDir,
6123
- files,
6124
- packageJson.scripts.test === currentGeneratedPackageScripts.test,
6125
- );
6253
+ const createdSupportFiles =
6254
+ packageJson.scripts.test === currentGeneratedPackageScripts.test
6255
+ ? missingTestSupportFiles(files)
6256
+ : [];
6126
6257
 
6127
6258
  if (next === original && createdSupportFiles.length === 0) return undefined;
6128
6259
 
6129
- if (next !== original) await writeFile(filePath, next);
6130
-
6260
+ let fix: InspectFix | undefined;
6131
6261
  if (addedTestScript) {
6132
- return {
6262
+ fix = {
6133
6263
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
6134
6264
  file: "package.json",
6135
6265
  message:
6136
6266
  createdSupportFiles.length > 0
6137
- ? `Added "test": "tsx lib/beignet-test-runner.ts" and ${createdSupportFiles.join(", ")}.`
6138
- : 'Added "test": "tsx lib/beignet-test-runner.ts".',
6267
+ ? `Added "test": "${currentGeneratedPackageScripts.test}" and ${createdSupportFiles.join(", ")}.`
6268
+ : `Added "test": "${currentGeneratedPackageScripts.test}".`,
6139
6269
  };
6140
- }
6141
-
6142
- if (updatedScripts.length > 0) {
6143
- return {
6270
+ } else if (updatedScripts.length > 0) {
6271
+ fix = {
6144
6272
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
6145
6273
  file: "package.json",
6146
6274
  message: packageScriptFixMessage(
@@ -6149,31 +6277,48 @@ async function fixPackageScripts(
6149
6277
  addedTsx,
6150
6278
  ),
6151
6279
  };
6152
- }
6153
-
6154
- if (createdSupportFiles.length > 0) {
6155
- return {
6280
+ } else if (createdSupportFiles.length > 0) {
6281
+ fix = {
6156
6282
  code: "BEIGNET_PACKAGE_TEST_SUPPORT_MISSING",
6157
6283
  file: "package.json",
6158
6284
  message: `Restored ${createdSupportFiles.join(", ")}.`,
6159
6285
  };
6160
- }
6161
-
6162
- if (addedTsx) {
6163
- return {
6286
+ } else if (addedTsx) {
6287
+ fix = {
6164
6288
  code: "BEIGNET_PACKAGE_TSX_DEPENDENCY_MISSING",
6165
6289
  file: "package.json",
6166
6290
  message: 'Added "tsx" to devDependencies.',
6167
6291
  };
6168
6292
  }
6169
6293
 
6170
- return undefined;
6294
+ if (!fix) return undefined;
6295
+
6296
+ return {
6297
+ id: "package.repair-generated-support",
6298
+ fixes: [fix],
6299
+ changes: [
6300
+ ...(next === original
6301
+ ? []
6302
+ : [
6303
+ planDoctorFixFileChange({
6304
+ file: "package.json",
6305
+ before: original,
6306
+ after: next,
6307
+ }),
6308
+ ]),
6309
+ ...testSupportTemplateFiles
6310
+ .filter((file) => createdSupportFiles.includes(file.path))
6311
+ .map((file) =>
6312
+ planDoctorFixFileChange({ file: file.path, after: file.content }),
6313
+ ),
6314
+ ],
6315
+ };
6171
6316
  }
6172
6317
 
6173
6318
  function legacyGeneratedScriptNames(scripts: Record<string, string>): string[] {
6174
- return Object.entries(legacyGeneratedPackageScripts)
6175
- .filter(([name, command]) => scripts[name] === command)
6176
- .map(([name]) => name);
6319
+ return Object.keys(legacyGeneratedPackageScripts).filter((name) =>
6320
+ isLegacyGeneratedPackageScript(name, scripts[name]),
6321
+ );
6177
6322
  }
6178
6323
 
6179
6324
  async function appUsesDirectBunTestImports(
@@ -6194,7 +6339,8 @@ function hasCurrentGeneratedTsxScript(
6194
6339
  ): boolean {
6195
6340
  return Object.values(currentGeneratedPackageScripts).some(
6196
6341
  (command) =>
6197
- command.startsWith("tsx ") && Object.values(scripts).includes(command),
6342
+ (command.startsWith("tsx ") || command.includes("--import tsx")) &&
6343
+ Object.values(scripts).includes(command),
6198
6344
  );
6199
6345
  }
6200
6346
 
@@ -6204,23 +6350,6 @@ function missingTestSupportFiles(files: string[]): string[] {
6204
6350
  .filter((file) => !files.includes(file));
6205
6351
  }
6206
6352
 
6207
- async function createMissingTestSupportFiles(
6208
- targetDir: string,
6209
- files: string[],
6210
- shouldCreate: boolean,
6211
- ): Promise<string[]> {
6212
- if (!shouldCreate) return [];
6213
- const createdSupportFiles: string[] = [];
6214
- for (const file of testSupportTemplateFiles) {
6215
- if (files.includes(file.path)) continue;
6216
- const destination = path.join(targetDir, file.path);
6217
- await mkdir(path.dirname(destination), { recursive: true });
6218
- await writeFile(destination, file.content);
6219
- createdSupportFiles.push(file.path);
6220
- }
6221
- return createdSupportFiles;
6222
- }
6223
-
6224
6353
  function packageScriptFixMessage(
6225
6354
  updatedScripts: string[],
6226
6355
  createdSupportFiles: string[],
@@ -6274,6 +6403,8 @@ function dedupeDiagnostics(
6274
6403
  diagnostic.code,
6275
6404
  diagnostic.file ?? "",
6276
6405
  diagnostic.contract ?? "",
6406
+ diagnostic.subject?.file ?? "",
6407
+ diagnostic.subject?.exportName ?? "",
6277
6408
  diagnostic.message,
6278
6409
  ].join(":");
6279
6410
  if (seen.has(key)) continue;
@@ -6463,6 +6594,10 @@ async function inspectFeatureRouteRegistration(
6463
6594
  code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
6464
6595
  file: config.paths.server,
6465
6596
  contract: routeGroupContract.exportName,
6597
+ subject: {
6598
+ file: routeGroup.file,
6599
+ exportName: routeGroup.name,
6600
+ },
6466
6601
  message: contract
6467
6602
  ? `${routeGroup.file} declares ${routeGroup.name} for ${contract.exportName} (${contract.method} ${contract.path}), but ${routeGroup.name} is not registered in defineRoutes([...]) in ${config.paths.server}. Import ${routeGroup.name} and add it to the central route list.`
6468
6603
  : `${routeGroup.file} declares ${routeGroup.name} for ${routeGroupContract.exportName}, but ${routeGroup.name} is not registered in defineRoutes([...]) in ${config.paths.server}. Import ${routeGroup.name} and add it to the central route list.`,
@@ -6584,44 +6719,8 @@ function balancedArraySource(
6584
6719
  startIndex: number,
6585
6720
  ): string | undefined {
6586
6721
  if (source[startIndex] !== "[") return undefined;
6587
-
6588
- let depth = 0;
6589
- let quote: '"' | "'" | "`" | undefined;
6590
- let escaped = false;
6591
-
6592
- for (let index = startIndex; index < source.length; index += 1) {
6593
- const char = source[index];
6594
-
6595
- if (quote) {
6596
- if (escaped) {
6597
- escaped = false;
6598
- continue;
6599
- }
6600
- if (char === "\\") {
6601
- escaped = true;
6602
- continue;
6603
- }
6604
- if (char === quote) quote = undefined;
6605
- continue;
6606
- }
6607
-
6608
- if (char === '"' || char === "'" || char === "`") {
6609
- quote = char;
6610
- continue;
6611
- }
6612
-
6613
- if (char === "[") {
6614
- depth += 1;
6615
- continue;
6616
- }
6617
-
6618
- if (char === "]") {
6619
- depth -= 1;
6620
- if (depth === 0) return source.slice(startIndex, index + 1);
6621
- }
6622
- }
6623
-
6624
- return undefined;
6722
+ const endIndex = matchingDelimiterIndex(source, startIndex, "[", "]");
6723
+ return endIndex === -1 ? undefined : source.slice(startIndex, endIndex + 1);
6625
6724
  }
6626
6725
 
6627
6726
  function routeGroupContractRef(
@@ -6651,21 +6750,10 @@ function routeGroupContractRef(
6651
6750
  }
6652
6751
 
6653
6752
  function registeredRouteGroups(source: string): Set<string> {
6654
- const defineRoutesMatch = /defineRoutes(?:<[^>]+>)?\(\[/.exec(source);
6655
- if (!defineRoutesMatch) return new Set();
6656
-
6657
- const defineRoutesSource = source.slice(defineRoutesMatch.index);
6658
- const defineRoutesEnd = defineRoutesSource.indexOf("]),");
6659
- const searchSource =
6660
- defineRoutesEnd === -1
6661
- ? defineRoutesSource
6662
- : defineRoutesSource.slice(0, defineRoutesEnd);
6663
-
6664
- return new Set(
6665
- [...searchSource.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)].map(
6666
- (match) => match[1],
6667
- ),
6668
- );
6753
+ const routes = firstDefineRoutesArgInfo(source);
6754
+ return routes?.text.startsWith("[")
6755
+ ? identifiersFromArrayExpression(routes.text)
6756
+ : new Set();
6669
6757
  }
6670
6758
 
6671
6759
  async function registeredRouteGroupsForServer(
@@ -6727,14 +6815,14 @@ function findRouteGroupContract(
6727
6815
  );
6728
6816
  }
6729
6817
 
6730
- async function fixDirectOpenApiArrayDrift(
6818
+ async function planDirectOpenApiArrayDrift(
6731
6819
  targetDir: string,
6732
6820
  files: string[],
6733
6821
  config: ResolvedBeignetConfig,
6734
6822
  convention: InspectConvention,
6735
6823
  contracts: InspectedContract[],
6736
6824
  matchedRoutes: MatchedRoutesResult,
6737
- ): Promise<InspectFix | undefined> {
6825
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6738
6826
  const routePath = path.join(targetDir, config.paths.openapiRoute);
6739
6827
  let source: string;
6740
6828
  try {
@@ -6777,21 +6865,32 @@ async function fixDirectOpenApiArrayDrift(
6777
6865
  )}`;
6778
6866
  if (next === source) return undefined;
6779
6867
 
6780
- await writeFile(routePath, next);
6781
6868
  return {
6782
- code: "BEIGNET_OPENAPI_MISSING",
6783
- file: config.paths.openapiRoute,
6784
- message: `Added ${missingContracts
6785
- .map((contract) => contract.exportName)
6786
- .join(", ")} to the direct createOpenAPIHandler contract array.`,
6869
+ id: "openapi.register-missing",
6870
+ fixes: [
6871
+ {
6872
+ code: "BEIGNET_OPENAPI_MISSING",
6873
+ file: config.paths.openapiRoute,
6874
+ message: `Added ${missingContracts
6875
+ .map((contract) => contract.exportName)
6876
+ .join(", ")} to the direct createOpenAPIHandler contract array.`,
6877
+ },
6878
+ ],
6879
+ changes: [
6880
+ planDoctorFixFileChange({
6881
+ file: config.paths.openapiRoute,
6882
+ before: source,
6883
+ after: next,
6884
+ }),
6885
+ ],
6787
6886
  };
6788
6887
  }
6789
6888
 
6790
- async function fixUnregisteredRouteGroups(
6889
+ async function planUnregisteredRouteGroups(
6791
6890
  targetDir: string,
6792
6891
  files: string[],
6793
6892
  config: ResolvedBeignetConfig,
6794
- ): Promise<InspectFix | undefined> {
6893
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6795
6894
  if (!files.includes(config.paths.server)) return undefined;
6796
6895
 
6797
6896
  const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
@@ -6862,25 +6961,36 @@ async function fixUnregisteredRouteGroups(
6862
6961
  )}`;
6863
6962
  if (next === original) return undefined;
6864
6963
 
6865
- await writeFile(targetPath, next);
6866
6964
  return {
6867
- code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
6868
- file: targetFile,
6869
- message: `Registered ${groupsToAppend
6870
- .map((routeGroup) => routeGroup.name)
6871
- .join(", ")} in the central route list.`,
6965
+ id: "routes.register-missing",
6966
+ fixes: [
6967
+ {
6968
+ code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
6969
+ file: targetFile,
6970
+ message: `Registered ${groupsToAppend
6971
+ .map((routeGroup) => routeGroup.name)
6972
+ .join(", ")} in the central route list.`,
6973
+ },
6974
+ ],
6975
+ changes: [
6976
+ planDoctorFixFileChange({
6977
+ file: targetFile,
6978
+ before: original,
6979
+ after: next,
6980
+ }),
6981
+ ],
6872
6982
  };
6873
6983
  }
6874
6984
 
6875
- async function fixUnregisteredSchedules(
6985
+ async function planUnregisteredSchedules(
6876
6986
  targetDir: string,
6877
6987
  files: string[],
6878
6988
  drift: WorkflowRegistrationDrift,
6879
6989
  config: ResolvedBeignetConfig,
6880
- ): Promise<InspectFix | undefined> {
6990
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6881
6991
  if (!drift.schedules.centralExists) return undefined;
6882
6992
 
6883
- return applyWorkflowRegistryFix({
6993
+ return planWorkflowRegistryOperation("schedules.register-missing", {
6884
6994
  targetDir,
6885
6995
  files,
6886
6996
  centralFile: config.paths.schedules,
@@ -6893,15 +7003,15 @@ async function fixUnregisteredSchedules(
6893
7003
  });
6894
7004
  }
6895
7005
 
6896
- async function fixUnregisteredTasks(
7006
+ async function planUnregisteredTasks(
6897
7007
  targetDir: string,
6898
7008
  files: string[],
6899
7009
  drift: WorkflowRegistrationDrift,
6900
7010
  config: ResolvedBeignetConfig,
6901
- ): Promise<InspectFix | undefined> {
7011
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6902
7012
  if (!drift.tasks.centralExists) return undefined;
6903
7013
 
6904
- return applyWorkflowRegistryFix({
7014
+ return planWorkflowRegistryOperation("tasks.register-missing", {
6905
7015
  targetDir,
6906
7016
  files,
6907
7017
  centralFile: config.paths.tasks,
@@ -6915,6 +7025,84 @@ async function fixUnregisteredTasks(
6915
7025
  });
6916
7026
  }
6917
7027
 
7028
+ async function planCoLocatedScheduleAndTaskRegistries(
7029
+ targetDir: string,
7030
+ files: string[],
7031
+ drift: WorkflowRegistrationDrift,
7032
+ config: ResolvedBeignetConfig,
7033
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7034
+ if (!drift.schedules.centralExists || !drift.tasks.centralExists) {
7035
+ return undefined;
7036
+ }
7037
+
7038
+ const centralFile = config.paths.schedules;
7039
+ let original: string;
7040
+ try {
7041
+ original = await readFile(path.join(targetDir, centralFile), "utf8");
7042
+ } catch {
7043
+ return undefined;
7044
+ }
7045
+
7046
+ let next = original;
7047
+ const fixes: InspectFix[] = [];
7048
+ const repairedKinds: Array<"schedules" | "tasks"> = [];
7049
+
7050
+ const scheduleEdit = planWorkflowRegistryEdit({
7051
+ files,
7052
+ centralFile,
7053
+ source: next,
7054
+ unregistered: drift.schedules.unregistered,
7055
+ code: "BEIGNET_SCHEDULE_UNREGISTERED",
7056
+ listName: "the schedules array",
7057
+ importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
7058
+ append: (source, entry, importLine) =>
7059
+ appendToNamedArray(source, "schedules", entry, importLine),
7060
+ });
7061
+ if (scheduleEdit) {
7062
+ next = scheduleEdit.after;
7063
+ fixes.push(scheduleEdit.fix);
7064
+ repairedKinds.push("schedules");
7065
+ }
7066
+
7067
+ const taskEdit = planWorkflowRegistryEdit({
7068
+ files,
7069
+ centralFile,
7070
+ source: next,
7071
+ unregistered: drift.tasks.unregistered,
7072
+ code: "BEIGNET_TASK_UNREGISTERED",
7073
+ listName: "defineTasks([...])",
7074
+ importSpecifier: (indexFile) => relativeModule(centralFile, indexFile),
7075
+ append: (source, entry, importLine) =>
7076
+ appendToNamedArray(source, "tasks", entry, importLine),
7077
+ });
7078
+ if (taskEdit) {
7079
+ next = taskEdit.after;
7080
+ fixes.push(taskEdit.fix);
7081
+ repairedKinds.push("tasks");
7082
+ }
7083
+
7084
+ if (fixes.length === 0 || next === original) return undefined;
7085
+
7086
+ const id: DoctorFixOperationId =
7087
+ repairedKinds.length === 2
7088
+ ? "workflows.register-missing"
7089
+ : repairedKinds[0] === "schedules"
7090
+ ? "schedules.register-missing"
7091
+ : "tasks.register-missing";
7092
+
7093
+ return {
7094
+ id,
7095
+ fixes,
7096
+ changes: [
7097
+ planDoctorFixFileChange({
7098
+ file: centralFile,
7099
+ before: original,
7100
+ after: next,
7101
+ }),
7102
+ ],
7103
+ };
7104
+ }
7105
+
6918
7106
  /**
6919
7107
  * Wire fully unregistered feature listener registries into the central
6920
7108
  * listener registry and ensure the providers file registers that central
@@ -6926,12 +7114,12 @@ async function fixUnregisteredTasks(
6926
7114
  * register against, or when the registry name is already imported from another
6927
7115
  * file. The diagnostic stays in every bail case.
6928
7116
  */
6929
- async function fixUnregisteredListeners(
7117
+ async function planUnregisteredListeners(
6930
7118
  targetDir: string,
6931
7119
  files: string[],
6932
7120
  drift: WorkflowRegistrationDrift,
6933
7121
  config: ResolvedBeignetConfig,
6934
- ): Promise<InspectFix | undefined> {
7122
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6935
7123
  const candidates = drift.listeners.unregistered.filter(
6936
7124
  (entry) => entry.fullyUnregistered,
6937
7125
  );
@@ -7028,32 +7216,61 @@ async function fixUnregisteredListeners(
7028
7216
  return undefined;
7029
7217
  }
7030
7218
 
7031
- await mkdir(path.dirname(listenersPath), { recursive: true });
7032
- await writeFile(listenersPath, nextListeners);
7033
- if (nextProviders !== originalProviders) {
7034
- await writeFile(providersPath, nextProviders);
7035
- }
7036
7219
  return {
7037
- code: "BEIGNET_LISTENER_UNREGISTERED",
7038
- file: listenersFile,
7039
- message: `Registered ${candidateNames.join(", ")} in ${listenersFile} and wired listeners with registerListeners(...) in ${providersFile}.`,
7220
+ id: "listeners.register-missing",
7221
+ fixes: [
7222
+ {
7223
+ code: "BEIGNET_LISTENER_UNREGISTERED",
7224
+ file: listenersFile,
7225
+ message: `Registered ${candidateNames.join(", ")} in ${listenersFile} and wired listeners with registerListeners(...) in ${providersFile}.`,
7226
+ },
7227
+ ],
7228
+ changes: [
7229
+ ...(nextListeners === originalListeners
7230
+ ? []
7231
+ : [
7232
+ planDoctorFixFileChange({
7233
+ file: listenersFile,
7234
+ before: originalListeners,
7235
+ after: nextListeners,
7236
+ }),
7237
+ ]),
7238
+ ...(nextProviders === originalProviders
7239
+ ? []
7240
+ : [
7241
+ planDoctorFixFileChange({
7242
+ file: providersFile,
7243
+ before: originalProviders,
7244
+ after: nextProviders,
7245
+ }),
7246
+ ]),
7247
+ ],
7040
7248
  };
7041
7249
  }
7042
7250
 
7043
- async function fixUnregisteredOutboxEntries(
7251
+ async function planUnregisteredOutboxEntries(
7044
7252
  targetDir: string,
7045
7253
  files: string[],
7046
7254
  drift: WorkflowRegistrationDrift,
7047
7255
  config: ResolvedBeignetConfig,
7048
- ): Promise<InspectFix[]> {
7049
- if (!drift.outbox.exists) return [];
7256
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7257
+ if (!drift.outbox.exists) return undefined;
7258
+
7259
+ const outboxPath = path.join(targetDir, config.paths.outbox);
7260
+ let original: string;
7261
+ try {
7262
+ original = await readFile(outboxPath, "utf8");
7263
+ } catch {
7264
+ return undefined;
7265
+ }
7050
7266
 
7267
+ let next = original;
7051
7268
  const fixes: InspectFix[] = [];
7052
7269
 
7053
- const eventFix = await applyWorkflowRegistryFix({
7054
- targetDir,
7270
+ const eventFix = planWorkflowRegistryEdit({
7055
7271
  files,
7056
7272
  centralFile: config.paths.outbox,
7273
+ source: next,
7057
7274
  unregistered: drift.outbox.events,
7058
7275
  code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
7059
7276
  listName: "the outbox events list",
@@ -7061,12 +7278,15 @@ async function fixUnregisteredOutboxEntries(
7061
7278
  append: (source, entry, importLine) =>
7062
7279
  appendToOutboxRegistryArray(source, "events", entry, importLine),
7063
7280
  });
7064
- if (eventFix) fixes.push(eventFix);
7281
+ if (eventFix) {
7282
+ next = eventFix.after;
7283
+ fixes.push(eventFix.fix);
7284
+ }
7065
7285
 
7066
- const jobFix = await applyWorkflowRegistryFix({
7067
- targetDir,
7286
+ const jobFix = planWorkflowRegistryEdit({
7068
7287
  files,
7069
7288
  centralFile: config.paths.outbox,
7289
+ source: next,
7070
7290
  unregistered: drift.outbox.jobs,
7071
7291
  code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
7072
7292
  listName: "the outbox jobs list",
@@ -7074,9 +7294,23 @@ async function fixUnregisteredOutboxEntries(
7074
7294
  append: (source, entry, importLine) =>
7075
7295
  appendToOutboxRegistryArray(source, "jobs", entry, importLine),
7076
7296
  });
7077
- if (jobFix) fixes.push(jobFix);
7297
+ if (jobFix) {
7298
+ next = jobFix.after;
7299
+ fixes.push(jobFix.fix);
7300
+ }
7078
7301
 
7079
- return fixes;
7302
+ if (fixes.length === 0 || next === original) return undefined;
7303
+ return {
7304
+ id: "outbox.register-missing",
7305
+ fixes,
7306
+ changes: [
7307
+ planDoctorFixFileChange({
7308
+ file: config.paths.outbox,
7309
+ before: original,
7310
+ after: next,
7311
+ }),
7312
+ ],
7313
+ };
7080
7314
  }
7081
7315
 
7082
7316
  /**
@@ -7088,7 +7322,7 @@ async function fixUnregisteredOutboxEntries(
7088
7322
  * some members are already individually registered (appending the registry
7089
7323
  * spread would run those members twice). The diagnostic stays in that case.
7090
7324
  */
7091
- async function applyWorkflowRegistryFix(options: {
7325
+ type WorkflowRegistryPlanOptions = {
7092
7326
  targetDir: string;
7093
7327
  files: string[];
7094
7328
  centralFile: string;
@@ -7097,12 +7331,12 @@ async function applyWorkflowRegistryFix(options: {
7097
7331
  listName: string;
7098
7332
  importSpecifier: (indexFile: string) => string;
7099
7333
  append: (source: string, entry: string, importLine?: string) => AppendResult;
7100
- }): Promise<InspectFix | undefined> {
7101
- const candidates = options.unregistered.filter(
7102
- (entry) => entry.fullyUnregistered,
7103
- );
7104
- if (candidates.length === 0) return undefined;
7334
+ };
7105
7335
 
7336
+ async function planWorkflowRegistryOperation(
7337
+ id: DoctorFixOperationId,
7338
+ options: WorkflowRegistryPlanOptions,
7339
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7106
7340
  const centralPath = path.join(options.targetDir, options.centralFile);
7107
7341
  let original: string;
7108
7342
  try {
@@ -7111,7 +7345,30 @@ async function applyWorkflowRegistryFix(options: {
7111
7345
  return undefined;
7112
7346
  }
7113
7347
 
7114
- let next = original;
7348
+ const edit = planWorkflowRegistryEdit({ ...options, source: original });
7349
+ if (!edit) return undefined;
7350
+ return {
7351
+ id,
7352
+ fixes: [edit.fix],
7353
+ changes: [
7354
+ planDoctorFixFileChange({
7355
+ file: options.centralFile,
7356
+ before: original,
7357
+ after: edit.after,
7358
+ }),
7359
+ ],
7360
+ };
7361
+ }
7362
+
7363
+ function planWorkflowRegistryEdit(
7364
+ options: Omit<WorkflowRegistryPlanOptions, "targetDir"> & { source: string },
7365
+ ): { after: string; fix: InspectFix } | undefined {
7366
+ const candidates = options.unregistered.filter(
7367
+ (entry) => entry.fullyUnregistered,
7368
+ );
7369
+ if (candidates.length === 0) return undefined;
7370
+
7371
+ let next = options.source;
7115
7372
  const registeredNames: string[] = [];
7116
7373
 
7117
7374
  for (const { registry } of candidates) {
@@ -7143,13 +7400,15 @@ async function applyWorkflowRegistryFix(options: {
7143
7400
  }
7144
7401
  }
7145
7402
 
7146
- if (next === original || registeredNames.length === 0) return undefined;
7403
+ if (next === options.source || registeredNames.length === 0) return undefined;
7147
7404
 
7148
- await writeFile(centralPath, next);
7149
7405
  return {
7150
- code: options.code,
7151
- file: options.centralFile,
7152
- message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
7406
+ after: next,
7407
+ fix: {
7408
+ code: options.code,
7409
+ file: options.centralFile,
7410
+ message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
7411
+ },
7153
7412
  };
7154
7413
  }
7155
7414
 
@@ -7347,26 +7606,9 @@ function firstCallArgInfo(
7347
7606
  argStart: number,
7348
7607
  ): { text: string; start: number; end: number } | undefined {
7349
7608
  let depth = 0;
7350
- let inString: string | undefined;
7351
- let escaped = false;
7352
7609
 
7353
- for (let index = argStart; index < source.length; index++) {
7610
+ for (const index of codeCharacterIndexes(source, argStart)) {
7354
7611
  const char = source[index];
7355
- if (inString) {
7356
- if (escaped) {
7357
- escaped = false;
7358
- } else if (char === "\\") {
7359
- escaped = true;
7360
- } else if (char === inString) {
7361
- inString = undefined;
7362
- }
7363
- continue;
7364
- }
7365
-
7366
- if (char === '"' || char === "'" || char === "`") {
7367
- inString = char;
7368
- continue;
7369
- }
7370
7612
 
7371
7613
  if (char === "[" || char === "(" || char === "{") {
7372
7614
  depth++;