@beignet/cli 0.0.41 → 0.0.42

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 (58) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +77 -21
  3. package/dist/choices.d.ts +18 -0
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +35 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/db.d.ts +18 -7
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +20 -7
  10. package/dist/db.js.map +1 -1
  11. package/dist/doctor-fixes.d.ts +64 -0
  12. package/dist/doctor-fixes.d.ts.map +1 -0
  13. package/dist/doctor-fixes.js +142 -0
  14. package/dist/doctor-fixes.js.map +1 -0
  15. package/dist/explain.d.ts +3 -1
  16. package/dist/explain.d.ts.map +1 -1
  17. package/dist/explain.js +136 -42
  18. package/dist/explain.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +92 -25
  21. package/dist/index.js.map +1 -1
  22. package/dist/inspect.d.ts +33 -9
  23. package/dist/inspect.d.ts.map +1 -1
  24. package/dist/inspect.js +353 -116
  25. package/dist/inspect.js.map +1 -1
  26. package/dist/lib.d.ts +6 -2
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +3 -2
  29. package/dist/lib.js.map +1 -1
  30. package/dist/make/shared.js +3 -3
  31. package/dist/make/shared.js.map +1 -1
  32. package/dist/mcp.d.ts.map +1 -1
  33. package/dist/mcp.js +121 -13
  34. package/dist/mcp.js.map +1 -1
  35. package/dist/templates/agents.d.ts.map +1 -1
  36. package/dist/templates/agents.js +26 -10
  37. package/dist/templates/agents.js.map +1 -1
  38. package/dist/templates/base.d.ts.map +1 -1
  39. package/dist/templates/base.js +3 -3
  40. package/dist/templates/base.js.map +1 -1
  41. package/dist/templates/shared.d.ts +2 -1
  42. package/dist/templates/shared.d.ts.map +1 -1
  43. package/dist/templates/shared.js +7 -4
  44. package/dist/templates/shared.js.map +1 -1
  45. package/package.json +3 -2
  46. package/skills/app-structure/SKILL.md +34 -10
  47. package/src/choices.ts +57 -0
  48. package/src/db.ts +45 -15
  49. package/src/doctor-fixes.ts +252 -0
  50. package/src/explain.ts +151 -43
  51. package/src/index.ts +130 -35
  52. package/src/inspect.ts +497 -145
  53. package/src/lib.ts +28 -1
  54. package/src/make/shared.ts +3 -3
  55. package/src/mcp.ts +187 -13
  56. package/src/templates/agents.ts +26 -10
  57. package/src/templates/base.ts +3 -2
  58. package/src/templates/shared.ts +14 -6
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,
@@ -55,6 +67,7 @@ import {
55
67
  import {
56
68
  currentGeneratedPackageScripts,
57
69
  externalVersions,
70
+ isLegacyGeneratedPackageScript,
58
71
  legacyGeneratedPackageScripts,
59
72
  } from "./templates/shared.js";
60
73
  import { testSupportTemplateFiles } from "./templates/testing.js";
@@ -119,14 +132,7 @@ export type InspectDiagnostic = {
119
132
  contract?: string;
120
133
  };
121
134
 
122
- /**
123
- * Automatic doctor fix that was applied.
124
- */
125
- export type InspectFix = {
126
- code: string;
127
- message: string;
128
- file: string;
129
- };
135
+ export type { InspectFix } from "./doctor-fixes.js";
130
136
 
131
137
  /**
132
138
  * Detected app convention and missing convention files.
@@ -157,6 +163,18 @@ export type InspectAppResult = {
157
163
  fixes: InspectFix[];
158
164
  };
159
165
 
166
+ /** Read-only doctor fix plan paired with the diagnostics it was derived from. */
167
+ export type DoctorFixPlanResult = DoctorFixPlan & {
168
+ convention: InspectConvention;
169
+ diagnostics: InspectDiagnostic[];
170
+ };
171
+
172
+ /** Post-apply inspection paired with the guarded plan metadata that ran. */
173
+ export type DoctorFixInspectionResult = InspectAppResult & {
174
+ planId: string;
175
+ operationIds: DoctorFixOperationId[];
176
+ };
177
+
160
178
  type RouteExport = {
161
179
  method: HttpMethod;
162
180
  handlerFile: string;
@@ -235,9 +253,66 @@ export async function inspectApp(
235
253
  };
236
254
  }
237
255
 
256
+ /** Options for guarded all-or-selected doctor fix application. */
257
+ export type ApplyDoctorFixPlanOptions = InspectAppOptions & {
258
+ planId: string;
259
+ fixIds?: readonly DoctorFixOperationId[];
260
+ };
261
+
262
+ /** Plan every currently available low-risk doctor repair without writing. */
263
+ export async function planDoctorFixes(
264
+ options: InspectAppOptions = {},
265
+ ): Promise<DoctorFixPlanResult> {
266
+ const plan = publicDoctorFixPlan(await buildDoctorFixPlan(options));
267
+ const inspected = await inspectApp(options);
268
+ return {
269
+ ...plan,
270
+ convention: inspected.convention,
271
+ diagnostics: inspected.diagnostics,
272
+ };
273
+ }
274
+
275
+ /** Apply a current doctor fix plan after verifying its plan ID. */
276
+ export async function applyDoctorFixPlan(
277
+ options: ApplyDoctorFixPlanOptions,
278
+ ): Promise<DoctorFixApplyResult> {
279
+ const plan = await buildDoctorFixPlan(options);
280
+ if (plan.planId !== options.planId) {
281
+ throw new Error(
282
+ "Doctor fix plan is stale because the available repairs changed. No files were changed; run beignet doctor --fix --dry-run again.",
283
+ );
284
+ }
285
+ return applyPlannedDoctorFixes(plan, options.fixIds);
286
+ }
287
+
238
288
  export async function applyDoctorFixes(
239
289
  options: InspectAppOptions = {},
240
290
  ): Promise<InspectFix[]> {
291
+ return (await applyDoctorFixesWithResult(options)).fixes;
292
+ }
293
+
294
+ export async function applyDoctorFixesWithResult(
295
+ options: InspectAppOptions = {},
296
+ ): Promise<DoctorFixApplyResult> {
297
+ const plan = await buildDoctorFixPlan(options);
298
+ return applyPlannedDoctorFixes(plan);
299
+ }
300
+
301
+ export function createDoctorFixInspectionResult(
302
+ inspected: InspectAppResult,
303
+ applied: DoctorFixApplyResult,
304
+ ): DoctorFixInspectionResult {
305
+ return {
306
+ ...inspected,
307
+ planId: applied.planId,
308
+ operationIds: applied.operationIds,
309
+ fixes: applied.fixes,
310
+ };
311
+ }
312
+
313
+ async function buildDoctorFixPlan(
314
+ options: InspectAppOptions = {},
315
+ ): Promise<PlannedDoctorFixPlan> {
241
316
  const targetDir = path.resolve(options.cwd ?? process.cwd());
242
317
  await assertDirectory(targetDir);
243
318
 
@@ -253,44 +328,63 @@ export async function applyDoctorFixes(
253
328
  config,
254
329
  contracts,
255
330
  );
256
- const fixes: InspectFix[] = [];
331
+ const operations: PlannedDoctorFixOperation[] = [];
257
332
 
258
- const packageFix = await fixPackageScripts(targetDir, files, convention);
259
- if (packageFix) fixes.push(packageFix);
333
+ const packageFix = await planPackageScripts(targetDir, files, convention);
334
+ if (packageFix) operations.push(packageFix);
260
335
 
261
- const routeGroupFix = await fixUnregisteredRouteGroups(
336
+ const routeGroupFix = await planUnregisteredRouteGroups(
262
337
  targetDir,
263
338
  files,
264
339
  config,
265
340
  );
266
- if (routeGroupFix) fixes.push(routeGroupFix);
341
+ if (routeGroupFix) operations.push(routeGroupFix);
267
342
 
268
343
  const drift = await workflowRegistrationDrift(targetDir, files, config);
269
344
 
270
- const scheduleFix = await fixUnregisteredSchedules(
345
+ if (config.paths.schedules === config.paths.tasks) {
346
+ const workflowFix = await planCoLocatedScheduleAndTaskRegistries(
347
+ targetDir,
348
+ files,
349
+ drift,
350
+ config,
351
+ );
352
+ if (workflowFix) operations.push(workflowFix);
353
+ } else {
354
+ const scheduleFix = await planUnregisteredSchedules(
355
+ targetDir,
356
+ files,
357
+ drift,
358
+ config,
359
+ );
360
+ if (scheduleFix) operations.push(scheduleFix);
361
+
362
+ const taskFix = await planUnregisteredTasks(
363
+ targetDir,
364
+ files,
365
+ drift,
366
+ config,
367
+ );
368
+ if (taskFix) operations.push(taskFix);
369
+ }
370
+
371
+ const outboxFix = await planUnregisteredOutboxEntries(
271
372
  targetDir,
272
373
  files,
273
374
  drift,
274
375
  config,
275
376
  );
276
- if (scheduleFix) fixes.push(scheduleFix);
377
+ if (outboxFix) operations.push(outboxFix);
277
378
 
278
- const taskFix = await fixUnregisteredTasks(targetDir, files, drift, config);
279
- if (taskFix) fixes.push(taskFix);
280
-
281
- fixes.push(
282
- ...(await fixUnregisteredOutboxEntries(targetDir, files, drift, config)),
283
- );
284
-
285
- const listenerFix = await fixUnregisteredListeners(
379
+ const listenerFix = await planUnregisteredListeners(
286
380
  targetDir,
287
381
  files,
288
382
  drift,
289
383
  config,
290
384
  );
291
- if (listenerFix) fixes.push(listenerFix);
385
+ if (listenerFix) operations.push(listenerFix);
292
386
 
293
- const openApiFix = await fixDirectOpenApiArrayDrift(
387
+ const openApiFix = await planDirectOpenApiArrayDrift(
294
388
  targetDir,
295
389
  files,
296
390
  config,
@@ -298,9 +392,13 @@ export async function applyDoctorFixes(
298
392
  contracts,
299
393
  matchedRoutes,
300
394
  );
301
- if (openApiFix) fixes.push(openApiFix);
395
+ if (openApiFix) operations.push(openApiFix);
302
396
 
303
- return fixes;
397
+ return createDoctorFixPlan({
398
+ targetDir,
399
+ strict: Boolean(options.strict),
400
+ operations,
401
+ });
304
402
  }
305
403
 
306
404
  /**
@@ -329,12 +427,97 @@ export function formatRoutes(result: InspectAppResult): string {
329
427
  ]);
330
428
  }
331
429
 
430
+ /**
431
+ * Format a read-only doctor fix plan with exact patches and apply commands.
432
+ */
433
+ export function formatDoctorFixPlan(
434
+ plan: DoctorFixPlanResult,
435
+ options: { color?: boolean } = {},
436
+ ): string {
437
+ const paint = createPainter(options.color);
438
+ const lines: string[] = [];
439
+ if (plan.operations.length === 0) {
440
+ lines.push(`No applicable Beignet fixes found in ${plan.targetDir}.`);
441
+ } else {
442
+ const strictFlag = plan.strict ? " --strict" : "";
443
+ lines.push(
444
+ `Planned ${plan.operations.length} Beignet fix operation${plan.operations.length === 1 ? "" : "s"} in ${plan.targetDir}:`,
445
+ "",
446
+ );
447
+ for (const operation of plan.operations) {
448
+ lines.push(
449
+ `${paint("PLAN", "cyan")} ${operation.id}`,
450
+ ...operation.fixes.map(
451
+ (fix) => ` ${fix.code} ${fix.file}\n ${fix.message}`,
452
+ ),
453
+ ...operation.changes.flatMap((change) => [
454
+ ` ${change.kind === "create" ? "Create" : "Update"} ${change.file}`,
455
+ change.patch.trimEnd(),
456
+ ]),
457
+ "",
458
+ );
459
+ }
460
+
461
+ lines.push(
462
+ `Plan ID: ${plan.planId}`,
463
+ "",
464
+ "Apply the complete plan:",
465
+ ` beignet doctor --fix${strictFlag} --plan ${plan.planId}`,
466
+ "",
467
+ "Apply selected operations:",
468
+ ` beignet doctor --fix${strictFlag} --plan ${plan.planId} --only ${plan.operations.map((operation) => operation.id).join(",")}`,
469
+ );
470
+ }
471
+
472
+ lines.push(
473
+ "",
474
+ formatDoctorResult(
475
+ {
476
+ targetDir: plan.targetDir,
477
+ strict: plan.strict,
478
+ diagnostics: plan.diagnostics,
479
+ fixes: [],
480
+ },
481
+ options,
482
+ ),
483
+ );
484
+ return lines.join("\n");
485
+ }
486
+
487
+ /**
488
+ * Format planned fixes as GitHub Actions notice annotations.
489
+ */
490
+ export function formatDoctorFixPlanGithub(plan: DoctorFixPlanResult): string {
491
+ return [
492
+ ...plan.operations.map((operation) =>
493
+ formatGithubAnnotation({
494
+ severity: "notice",
495
+ file: operation.changes[0]?.file,
496
+ message: `${operation.id}: ${operation.fixes.map((fix) => fix.message).join(" ")} Plan ${plan.planId}.`,
497
+ }),
498
+ ),
499
+ ...formatDoctorDiagnosticsGithub(plan.diagnostics),
500
+ ].join("\n");
501
+ }
502
+
503
+ type DoctorFormatResult = Pick<
504
+ InspectAppResult,
505
+ "targetDir" | "strict" | "diagnostics" | "fixes"
506
+ >;
507
+
332
508
  /**
333
509
  * Format doctor diagnostics and applied fixes for CLI output.
334
510
  */
335
511
  export function formatDoctor(
336
512
  result: InspectAppResult,
337
513
  options: { color?: boolean } = {},
514
+ ): string {
515
+ return formatDoctorResult(result, options);
516
+ }
517
+
518
+ function formatDoctorResult(
519
+ result: DoctorFormatResult,
520
+ options: { color?: boolean } = {},
338
521
  ): string {
339
522
  const paint = createPainter(options.color);
340
523
  const fixLines =
@@ -456,7 +639,7 @@ function productionHardeningChecklist(
456
639
  ];
457
640
  }
458
641
 
459
- function formatDoctorFooter(result: InspectAppResult): string {
642
+ function formatDoctorFooter(result: DoctorFormatResult): string {
460
643
  const count = (severity: InspectDiagnostic["severity"]) =>
461
644
  result.diagnostics.filter((diagnostic) => diagnostic.severity === severity)
462
645
  .length;
@@ -469,15 +652,19 @@ function formatDoctorFooter(result: InspectAppResult): string {
469
652
  * Format doctor diagnostics as GitHub Actions annotations.
470
653
  */
471
654
  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");
655
+ return formatDoctorDiagnosticsGithub(result.diagnostics).join("\n");
656
+ }
657
+
658
+ function formatDoctorDiagnosticsGithub(
659
+ diagnostics: InspectDiagnostic[],
660
+ ): string[] {
661
+ return diagnostics.map((diagnostic) =>
662
+ formatGithubAnnotation({
663
+ severity: githubAnnotationSeverity(diagnostic.severity),
664
+ file: diagnostic.file,
665
+ message: `${diagnostic.code}: ${diagnostic.message}`,
666
+ }),
667
+ );
481
668
  }
482
669
 
483
670
  function githubAnnotationSeverity(
@@ -6020,8 +6207,7 @@ async function inspectPackageScripts(
6020
6207
  severity: "warning",
6021
6208
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
6022
6209
  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.',
6210
+ message: `package.json does not define a test script. Add "test": "${currentGeneratedPackageScripts.test}" or run beignet doctor --fix.`,
6025
6211
  });
6026
6212
  }
6027
6213
 
@@ -6033,7 +6219,7 @@ async function inspectPackageScripts(
6033
6219
  severity: "warning",
6034
6220
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
6035
6221
  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.`,
6222
+ 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
6223
  });
6038
6224
  }
6039
6225
 
@@ -6067,11 +6253,11 @@ async function inspectPackageScripts(
6067
6253
  return diagnostics;
6068
6254
  }
6069
6255
 
6070
- async function fixPackageScripts(
6256
+ async function planPackageScripts(
6071
6257
  targetDir: string,
6072
6258
  files: string[],
6073
6259
  convention: InspectConvention,
6074
- ): Promise<InspectFix | undefined> {
6260
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6075
6261
  if (!convention.resourceGenerator || !files.includes("package.json")) {
6076
6262
  return undefined;
6077
6263
  }
@@ -6097,10 +6283,8 @@ async function fixPackageScripts(
6097
6283
  }
6098
6284
 
6099
6285
  if (!hasDirectBunTestImports) {
6100
- for (const [name, legacyCommand] of Object.entries(
6101
- legacyGeneratedPackageScripts,
6102
- )) {
6103
- if (packageJson.scripts[name] === legacyCommand) {
6286
+ for (const name of Object.keys(legacyGeneratedPackageScripts)) {
6287
+ if (isLegacyGeneratedPackageScript(name, packageJson.scripts[name])) {
6104
6288
  packageJson.scripts[name] = currentGeneratedPackageScripts[name];
6105
6289
  if (!updatedScripts.includes(name)) updatedScripts.push(name);
6106
6290
  }
@@ -6118,29 +6302,25 @@ async function fixPackageScripts(
6118
6302
  }
6119
6303
 
6120
6304
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
6121
- const createdSupportFiles = await createMissingTestSupportFiles(
6122
- targetDir,
6123
- files,
6124
- packageJson.scripts.test === currentGeneratedPackageScripts.test,
6125
- );
6305
+ const createdSupportFiles =
6306
+ packageJson.scripts.test === currentGeneratedPackageScripts.test
6307
+ ? missingTestSupportFiles(files)
6308
+ : [];
6126
6309
 
6127
6310
  if (next === original && createdSupportFiles.length === 0) return undefined;
6128
6311
 
6129
- if (next !== original) await writeFile(filePath, next);
6130
-
6312
+ let fix: InspectFix | undefined;
6131
6313
  if (addedTestScript) {
6132
- return {
6314
+ fix = {
6133
6315
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
6134
6316
  file: "package.json",
6135
6317
  message:
6136
6318
  createdSupportFiles.length > 0
6137
- ? `Added "test": "tsx lib/beignet-test-runner.ts" and ${createdSupportFiles.join(", ")}.`
6138
- : 'Added "test": "tsx lib/beignet-test-runner.ts".',
6319
+ ? `Added "test": "${currentGeneratedPackageScripts.test}" and ${createdSupportFiles.join(", ")}.`
6320
+ : `Added "test": "${currentGeneratedPackageScripts.test}".`,
6139
6321
  };
6140
- }
6141
-
6142
- if (updatedScripts.length > 0) {
6143
- return {
6322
+ } else if (updatedScripts.length > 0) {
6323
+ fix = {
6144
6324
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
6145
6325
  file: "package.json",
6146
6326
  message: packageScriptFixMessage(
@@ -6149,31 +6329,48 @@ async function fixPackageScripts(
6149
6329
  addedTsx,
6150
6330
  ),
6151
6331
  };
6152
- }
6153
-
6154
- if (createdSupportFiles.length > 0) {
6155
- return {
6332
+ } else if (createdSupportFiles.length > 0) {
6333
+ fix = {
6156
6334
  code: "BEIGNET_PACKAGE_TEST_SUPPORT_MISSING",
6157
6335
  file: "package.json",
6158
6336
  message: `Restored ${createdSupportFiles.join(", ")}.`,
6159
6337
  };
6160
- }
6161
-
6162
- if (addedTsx) {
6163
- return {
6338
+ } else if (addedTsx) {
6339
+ fix = {
6164
6340
  code: "BEIGNET_PACKAGE_TSX_DEPENDENCY_MISSING",
6165
6341
  file: "package.json",
6166
6342
  message: 'Added "tsx" to devDependencies.',
6167
6343
  };
6168
6344
  }
6169
6345
 
6170
- return undefined;
6346
+ if (!fix) return undefined;
6347
+
6348
+ return {
6349
+ id: "package.repair-generated-support",
6350
+ fixes: [fix],
6351
+ changes: [
6352
+ ...(next === original
6353
+ ? []
6354
+ : [
6355
+ planDoctorFixFileChange({
6356
+ file: "package.json",
6357
+ before: original,
6358
+ after: next,
6359
+ }),
6360
+ ]),
6361
+ ...testSupportTemplateFiles
6362
+ .filter((file) => createdSupportFiles.includes(file.path))
6363
+ .map((file) =>
6364
+ planDoctorFixFileChange({ file: file.path, after: file.content }),
6365
+ ),
6366
+ ],
6367
+ };
6171
6368
  }
6172
6369
 
6173
6370
  function legacyGeneratedScriptNames(scripts: Record<string, string>): string[] {
6174
- return Object.entries(legacyGeneratedPackageScripts)
6175
- .filter(([name, command]) => scripts[name] === command)
6176
- .map(([name]) => name);
6371
+ return Object.keys(legacyGeneratedPackageScripts).filter((name) =>
6372
+ isLegacyGeneratedPackageScript(name, scripts[name]),
6373
+ );
6177
6374
  }
6178
6375
 
6179
6376
  async function appUsesDirectBunTestImports(
@@ -6194,7 +6391,8 @@ function hasCurrentGeneratedTsxScript(
6194
6391
  ): boolean {
6195
6392
  return Object.values(currentGeneratedPackageScripts).some(
6196
6393
  (command) =>
6197
- command.startsWith("tsx ") && Object.values(scripts).includes(command),
6394
+ (command.startsWith("tsx ") || command.includes("--import tsx")) &&
6395
+ Object.values(scripts).includes(command),
6198
6396
  );
6199
6397
  }
6200
6398
 
@@ -6204,23 +6402,6 @@ function missingTestSupportFiles(files: string[]): string[] {
6204
6402
  .filter((file) => !files.includes(file));
6205
6403
  }
6206
6404
 
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
6405
  function packageScriptFixMessage(
6225
6406
  updatedScripts: string[],
6226
6407
  createdSupportFiles: string[],
@@ -6727,14 +6908,14 @@ function findRouteGroupContract(
6727
6908
  );
6728
6909
  }
6729
6910
 
6730
- async function fixDirectOpenApiArrayDrift(
6911
+ async function planDirectOpenApiArrayDrift(
6731
6912
  targetDir: string,
6732
6913
  files: string[],
6733
6914
  config: ResolvedBeignetConfig,
6734
6915
  convention: InspectConvention,
6735
6916
  contracts: InspectedContract[],
6736
6917
  matchedRoutes: MatchedRoutesResult,
6737
- ): Promise<InspectFix | undefined> {
6918
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6738
6919
  const routePath = path.join(targetDir, config.paths.openapiRoute);
6739
6920
  let source: string;
6740
6921
  try {
@@ -6777,21 +6958,32 @@ async function fixDirectOpenApiArrayDrift(
6777
6958
  )}`;
6778
6959
  if (next === source) return undefined;
6779
6960
 
6780
- await writeFile(routePath, next);
6781
6961
  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.`,
6962
+ id: "openapi.register-missing",
6963
+ fixes: [
6964
+ {
6965
+ code: "BEIGNET_OPENAPI_MISSING",
6966
+ file: config.paths.openapiRoute,
6967
+ message: `Added ${missingContracts
6968
+ .map((contract) => contract.exportName)
6969
+ .join(", ")} to the direct createOpenAPIHandler contract array.`,
6970
+ },
6971
+ ],
6972
+ changes: [
6973
+ planDoctorFixFileChange({
6974
+ file: config.paths.openapiRoute,
6975
+ before: source,
6976
+ after: next,
6977
+ }),
6978
+ ],
6787
6979
  };
6788
6980
  }
6789
6981
 
6790
- async function fixUnregisteredRouteGroups(
6982
+ async function planUnregisteredRouteGroups(
6791
6983
  targetDir: string,
6792
6984
  files: string[],
6793
6985
  config: ResolvedBeignetConfig,
6794
- ): Promise<InspectFix | undefined> {
6986
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6795
6987
  if (!files.includes(config.paths.server)) return undefined;
6796
6988
 
6797
6989
  const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
@@ -6862,25 +7054,36 @@ async function fixUnregisteredRouteGroups(
6862
7054
  )}`;
6863
7055
  if (next === original) return undefined;
6864
7056
 
6865
- await writeFile(targetPath, next);
6866
7057
  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.`,
7058
+ id: "routes.register-missing",
7059
+ fixes: [
7060
+ {
7061
+ code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
7062
+ file: targetFile,
7063
+ message: `Registered ${groupsToAppend
7064
+ .map((routeGroup) => routeGroup.name)
7065
+ .join(", ")} in the central route list.`,
7066
+ },
7067
+ ],
7068
+ changes: [
7069
+ planDoctorFixFileChange({
7070
+ file: targetFile,
7071
+ before: original,
7072
+ after: next,
7073
+ }),
7074
+ ],
6872
7075
  };
6873
7076
  }
6874
7077
 
6875
- async function fixUnregisteredSchedules(
7078
+ async function planUnregisteredSchedules(
6876
7079
  targetDir: string,
6877
7080
  files: string[],
6878
7081
  drift: WorkflowRegistrationDrift,
6879
7082
  config: ResolvedBeignetConfig,
6880
- ): Promise<InspectFix | undefined> {
7083
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6881
7084
  if (!drift.schedules.centralExists) return undefined;
6882
7085
 
6883
- return applyWorkflowRegistryFix({
7086
+ return planWorkflowRegistryOperation("schedules.register-missing", {
6884
7087
  targetDir,
6885
7088
  files,
6886
7089
  centralFile: config.paths.schedules,
@@ -6893,15 +7096,15 @@ async function fixUnregisteredSchedules(
6893
7096
  });
6894
7097
  }
6895
7098
 
6896
- async function fixUnregisteredTasks(
7099
+ async function planUnregisteredTasks(
6897
7100
  targetDir: string,
6898
7101
  files: string[],
6899
7102
  drift: WorkflowRegistrationDrift,
6900
7103
  config: ResolvedBeignetConfig,
6901
- ): Promise<InspectFix | undefined> {
7104
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6902
7105
  if (!drift.tasks.centralExists) return undefined;
6903
7106
 
6904
- return applyWorkflowRegistryFix({
7107
+ return planWorkflowRegistryOperation("tasks.register-missing", {
6905
7108
  targetDir,
6906
7109
  files,
6907
7110
  centralFile: config.paths.tasks,
@@ -6915,6 +7118,84 @@ async function fixUnregisteredTasks(
6915
7118
  });
6916
7119
  }
6917
7120
 
7121
+ async function planCoLocatedScheduleAndTaskRegistries(
7122
+ targetDir: string,
7123
+ files: string[],
7124
+ drift: WorkflowRegistrationDrift,
7125
+ config: ResolvedBeignetConfig,
7126
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7127
+ if (!drift.schedules.centralExists || !drift.tasks.centralExists) {
7128
+ return undefined;
7129
+ }
7130
+
7131
+ const centralFile = config.paths.schedules;
7132
+ let original: string;
7133
+ try {
7134
+ original = await readFile(path.join(targetDir, centralFile), "utf8");
7135
+ } catch {
7136
+ return undefined;
7137
+ }
7138
+
7139
+ let next = original;
7140
+ const fixes: InspectFix[] = [];
7141
+ const repairedKinds: Array<"schedules" | "tasks"> = [];
7142
+
7143
+ const scheduleEdit = planWorkflowRegistryEdit({
7144
+ files,
7145
+ centralFile,
7146
+ source: next,
7147
+ unregistered: drift.schedules.unregistered,
7148
+ code: "BEIGNET_SCHEDULE_UNREGISTERED",
7149
+ listName: "the schedules array",
7150
+ importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
7151
+ append: (source, entry, importLine) =>
7152
+ appendToNamedArray(source, "schedules", entry, importLine),
7153
+ });
7154
+ if (scheduleEdit) {
7155
+ next = scheduleEdit.after;
7156
+ fixes.push(scheduleEdit.fix);
7157
+ repairedKinds.push("schedules");
7158
+ }
7159
+
7160
+ const taskEdit = planWorkflowRegistryEdit({
7161
+ files,
7162
+ centralFile,
7163
+ source: next,
7164
+ unregistered: drift.tasks.unregistered,
7165
+ code: "BEIGNET_TASK_UNREGISTERED",
7166
+ listName: "defineTasks([...])",
7167
+ importSpecifier: (indexFile) => relativeModule(centralFile, indexFile),
7168
+ append: (source, entry, importLine) =>
7169
+ appendToNamedArray(source, "tasks", entry, importLine),
7170
+ });
7171
+ if (taskEdit) {
7172
+ next = taskEdit.after;
7173
+ fixes.push(taskEdit.fix);
7174
+ repairedKinds.push("tasks");
7175
+ }
7176
+
7177
+ if (fixes.length === 0 || next === original) return undefined;
7178
+
7179
+ const id: DoctorFixOperationId =
7180
+ repairedKinds.length === 2
7181
+ ? "workflows.register-missing"
7182
+ : repairedKinds[0] === "schedules"
7183
+ ? "schedules.register-missing"
7184
+ : "tasks.register-missing";
7185
+
7186
+ return {
7187
+ id,
7188
+ fixes,
7189
+ changes: [
7190
+ planDoctorFixFileChange({
7191
+ file: centralFile,
7192
+ before: original,
7193
+ after: next,
7194
+ }),
7195
+ ],
7196
+ };
7197
+ }
7198
+
6918
7199
  /**
6919
7200
  * Wire fully unregistered feature listener registries into the central
6920
7201
  * listener registry and ensure the providers file registers that central
@@ -6926,12 +7207,12 @@ async function fixUnregisteredTasks(
6926
7207
  * register against, or when the registry name is already imported from another
6927
7208
  * file. The diagnostic stays in every bail case.
6928
7209
  */
6929
- async function fixUnregisteredListeners(
7210
+ async function planUnregisteredListeners(
6930
7211
  targetDir: string,
6931
7212
  files: string[],
6932
7213
  drift: WorkflowRegistrationDrift,
6933
7214
  config: ResolvedBeignetConfig,
6934
- ): Promise<InspectFix | undefined> {
7215
+ ): Promise<PlannedDoctorFixOperation | undefined> {
6935
7216
  const candidates = drift.listeners.unregistered.filter(
6936
7217
  (entry) => entry.fullyUnregistered,
6937
7218
  );
@@ -7028,32 +7309,61 @@ async function fixUnregisteredListeners(
7028
7309
  return undefined;
7029
7310
  }
7030
7311
 
7031
- await mkdir(path.dirname(listenersPath), { recursive: true });
7032
- await writeFile(listenersPath, nextListeners);
7033
- if (nextProviders !== originalProviders) {
7034
- await writeFile(providersPath, nextProviders);
7035
- }
7036
7312
  return {
7037
- code: "BEIGNET_LISTENER_UNREGISTERED",
7038
- file: listenersFile,
7039
- message: `Registered ${candidateNames.join(", ")} in ${listenersFile} and wired listeners with registerListeners(...) in ${providersFile}.`,
7313
+ id: "listeners.register-missing",
7314
+ fixes: [
7315
+ {
7316
+ code: "BEIGNET_LISTENER_UNREGISTERED",
7317
+ file: listenersFile,
7318
+ message: `Registered ${candidateNames.join(", ")} in ${listenersFile} and wired listeners with registerListeners(...) in ${providersFile}.`,
7319
+ },
7320
+ ],
7321
+ changes: [
7322
+ ...(nextListeners === originalListeners
7323
+ ? []
7324
+ : [
7325
+ planDoctorFixFileChange({
7326
+ file: listenersFile,
7327
+ before: originalListeners,
7328
+ after: nextListeners,
7329
+ }),
7330
+ ]),
7331
+ ...(nextProviders === originalProviders
7332
+ ? []
7333
+ : [
7334
+ planDoctorFixFileChange({
7335
+ file: providersFile,
7336
+ before: originalProviders,
7337
+ after: nextProviders,
7338
+ }),
7339
+ ]),
7340
+ ],
7040
7341
  };
7041
7342
  }
7042
7343
 
7043
- async function fixUnregisteredOutboxEntries(
7344
+ async function planUnregisteredOutboxEntries(
7044
7345
  targetDir: string,
7045
7346
  files: string[],
7046
7347
  drift: WorkflowRegistrationDrift,
7047
7348
  config: ResolvedBeignetConfig,
7048
- ): Promise<InspectFix[]> {
7049
- if (!drift.outbox.exists) return [];
7349
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7350
+ if (!drift.outbox.exists) return undefined;
7351
+
7352
+ const outboxPath = path.join(targetDir, config.paths.outbox);
7353
+ let original: string;
7354
+ try {
7355
+ original = await readFile(outboxPath, "utf8");
7356
+ } catch {
7357
+ return undefined;
7358
+ }
7050
7359
 
7360
+ let next = original;
7051
7361
  const fixes: InspectFix[] = [];
7052
7362
 
7053
- const eventFix = await applyWorkflowRegistryFix({
7054
- targetDir,
7363
+ const eventFix = planWorkflowRegistryEdit({
7055
7364
  files,
7056
7365
  centralFile: config.paths.outbox,
7366
+ source: next,
7057
7367
  unregistered: drift.outbox.events,
7058
7368
  code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
7059
7369
  listName: "the outbox events list",
@@ -7061,12 +7371,15 @@ async function fixUnregisteredOutboxEntries(
7061
7371
  append: (source, entry, importLine) =>
7062
7372
  appendToOutboxRegistryArray(source, "events", entry, importLine),
7063
7373
  });
7064
- if (eventFix) fixes.push(eventFix);
7374
+ if (eventFix) {
7375
+ next = eventFix.after;
7376
+ fixes.push(eventFix.fix);
7377
+ }
7065
7378
 
7066
- const jobFix = await applyWorkflowRegistryFix({
7067
- targetDir,
7379
+ const jobFix = planWorkflowRegistryEdit({
7068
7380
  files,
7069
7381
  centralFile: config.paths.outbox,
7382
+ source: next,
7070
7383
  unregistered: drift.outbox.jobs,
7071
7384
  code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
7072
7385
  listName: "the outbox jobs list",
@@ -7074,9 +7387,23 @@ async function fixUnregisteredOutboxEntries(
7074
7387
  append: (source, entry, importLine) =>
7075
7388
  appendToOutboxRegistryArray(source, "jobs", entry, importLine),
7076
7389
  });
7077
- if (jobFix) fixes.push(jobFix);
7390
+ if (jobFix) {
7391
+ next = jobFix.after;
7392
+ fixes.push(jobFix.fix);
7393
+ }
7078
7394
 
7079
- return fixes;
7395
+ if (fixes.length === 0 || next === original) return undefined;
7396
+ return {
7397
+ id: "outbox.register-missing",
7398
+ fixes,
7399
+ changes: [
7400
+ planDoctorFixFileChange({
7401
+ file: config.paths.outbox,
7402
+ before: original,
7403
+ after: next,
7404
+ }),
7405
+ ],
7406
+ };
7080
7407
  }
7081
7408
 
7082
7409
  /**
@@ -7088,7 +7415,7 @@ async function fixUnregisteredOutboxEntries(
7088
7415
  * some members are already individually registered (appending the registry
7089
7416
  * spread would run those members twice). The diagnostic stays in that case.
7090
7417
  */
7091
- async function applyWorkflowRegistryFix(options: {
7418
+ type WorkflowRegistryPlanOptions = {
7092
7419
  targetDir: string;
7093
7420
  files: string[];
7094
7421
  centralFile: string;
@@ -7097,12 +7424,12 @@ async function applyWorkflowRegistryFix(options: {
7097
7424
  listName: string;
7098
7425
  importSpecifier: (indexFile: string) => string;
7099
7426
  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;
7427
+ };
7105
7428
 
7429
+ async function planWorkflowRegistryOperation(
7430
+ id: DoctorFixOperationId,
7431
+ options: WorkflowRegistryPlanOptions,
7432
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7106
7433
  const centralPath = path.join(options.targetDir, options.centralFile);
7107
7434
  let original: string;
7108
7435
  try {
@@ -7111,7 +7438,30 @@ async function applyWorkflowRegistryFix(options: {
7111
7438
  return undefined;
7112
7439
  }
7113
7440
 
7114
- let next = original;
7441
+ const edit = planWorkflowRegistryEdit({ ...options, source: original });
7442
+ if (!edit) return undefined;
7443
+ return {
7444
+ id,
7445
+ fixes: [edit.fix],
7446
+ changes: [
7447
+ planDoctorFixFileChange({
7448
+ file: options.centralFile,
7449
+ before: original,
7450
+ after: edit.after,
7451
+ }),
7452
+ ],
7453
+ };
7454
+ }
7455
+
7456
+ function planWorkflowRegistryEdit(
7457
+ options: Omit<WorkflowRegistryPlanOptions, "targetDir"> & { source: string },
7458
+ ): { after: string; fix: InspectFix } | undefined {
7459
+ const candidates = options.unregistered.filter(
7460
+ (entry) => entry.fullyUnregistered,
7461
+ );
7462
+ if (candidates.length === 0) return undefined;
7463
+
7464
+ let next = options.source;
7115
7465
  const registeredNames: string[] = [];
7116
7466
 
7117
7467
  for (const { registry } of candidates) {
@@ -7143,13 +7493,15 @@ async function applyWorkflowRegistryFix(options: {
7143
7493
  }
7144
7494
  }
7145
7495
 
7146
- if (next === original || registeredNames.length === 0) return undefined;
7496
+ if (next === options.source || registeredNames.length === 0) return undefined;
7147
7497
 
7148
- await writeFile(centralPath, next);
7149
7498
  return {
7150
- code: options.code,
7151
- file: options.centralFile,
7152
- message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
7499
+ after: next,
7500
+ fix: {
7501
+ code: options.code,
7502
+ file: options.centralFile,
7503
+ message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
7504
+ },
7153
7505
  };
7154
7506
  }
7155
7507