@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/dist/inspect.js CHANGED
@@ -1,13 +1,14 @@
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";
5
5
  import { clientDirPath, clientFormsPath, clientIndexPath, defaultBeignetConfig, directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
6
+ import { applyPlannedDoctorFixes, createDoctorFixPlan, planDoctorFixFileChange, publicDoctorFixPlan, } from "./doctor-fixes.js";
6
7
  import { formatGithubAnnotation, } from "./github-annotations.js";
7
8
  import { createListenersRegistrySource, listenersFilePath, providersFilePath, updateListenersRegistrySource, wireListenersProviderSource, } from "./make/shared.js";
8
9
  import { detectedProviderVariants, diagnosticPackageJsonFile, formatProviderMetadataIssues, installedPackageNames, providerDoctorRulesForInstalledPackages, providerOperationalConfigFiles, providerRequiredEnvExists, providerRequiredTableExists, providerVariantRegistrationSeverity, readProviderListEntries, readProviderPackageMetadataSource, registrationTokenHint, serverProviderFiles, } from "./provider-audit.js";
9
10
  import { appendToArrayExpression, appendToNamedArray, appendToOutboxRegistryArray, arrayInitializerInfo, identifiersFromArrayExpression, insertAfterImports, matchingDelimiterIndex, outboxRegistryValueInfo, } from "./registry-edits.js";
10
- import { currentGeneratedPackageScripts, externalVersions, legacyGeneratedPackageScripts, } from "./templates/shared.js";
11
+ import { currentGeneratedPackageScripts, externalVersions, isLegacyGeneratedPackageScript, legacyGeneratedPackageScripts, } from "./templates/shared.js";
11
12
  import { testSupportTemplateFiles } from "./templates/testing.js";
12
13
  import { getCliVersion } from "./version.js";
13
14
  export async function inspectApp(options = {}) {
@@ -33,7 +34,40 @@ export async function inspectApp(options = {}) {
33
34
  fixes: [],
34
35
  };
35
36
  }
37
+ /** Plan every currently available low-risk doctor repair without writing. */
38
+ export async function planDoctorFixes(options = {}) {
39
+ const plan = publicDoctorFixPlan(await buildDoctorFixPlan(options));
40
+ const inspected = await inspectApp(options);
41
+ return {
42
+ ...plan,
43
+ convention: inspected.convention,
44
+ diagnostics: inspected.diagnostics,
45
+ };
46
+ }
47
+ /** Apply a current doctor fix plan after verifying its plan ID. */
48
+ export async function applyDoctorFixPlan(options) {
49
+ const plan = await buildDoctorFixPlan(options);
50
+ if (plan.planId !== options.planId) {
51
+ throw new Error("Doctor fix plan is stale because the available repairs changed. No files were changed; run beignet doctor --fix --dry-run again.");
52
+ }
53
+ return applyPlannedDoctorFixes(plan, options.fixIds);
54
+ }
36
55
  export async function applyDoctorFixes(options = {}) {
56
+ return (await applyDoctorFixesWithResult(options)).fixes;
57
+ }
58
+ export async function applyDoctorFixesWithResult(options = {}) {
59
+ const plan = await buildDoctorFixPlan(options);
60
+ return applyPlannedDoctorFixes(plan);
61
+ }
62
+ export function createDoctorFixInspectionResult(inspected, applied) {
63
+ return {
64
+ ...inspected,
65
+ planId: applied.planId,
66
+ operationIds: applied.operationIds,
67
+ fixes: applied.fixes,
68
+ };
69
+ }
70
+ async function buildDoctorFixPlan(options = {}) {
37
71
  const targetDir = path.resolve(options.cwd ?? process.cwd());
38
72
  await assertDirectory(targetDir);
39
73
  const files = await listFiles(targetDir);
@@ -43,28 +77,41 @@ export async function applyDoctorFixes(options = {}) {
43
77
  const convention = inspectConvention(files, config);
44
78
  const contracts = await readContracts(targetDir, files, config);
45
79
  const matchedRoutes = await inspectRouteSurface(targetDir, files, config, contracts);
46
- const fixes = [];
47
- const packageFix = await fixPackageScripts(targetDir, files, convention);
80
+ const operations = [];
81
+ const packageFix = await planPackageScripts(targetDir, files, convention);
48
82
  if (packageFix)
49
- fixes.push(packageFix);
50
- const routeGroupFix = await fixUnregisteredRouteGroups(targetDir, files, config);
83
+ operations.push(packageFix);
84
+ const routeGroupFix = await planUnregisteredRouteGroups(targetDir, files, config);
51
85
  if (routeGroupFix)
52
- fixes.push(routeGroupFix);
86
+ operations.push(routeGroupFix);
53
87
  const drift = await workflowRegistrationDrift(targetDir, files, config);
54
- const scheduleFix = await fixUnregisteredSchedules(targetDir, files, drift, config);
55
- if (scheduleFix)
56
- fixes.push(scheduleFix);
57
- const taskFix = await fixUnregisteredTasks(targetDir, files, drift, config);
58
- if (taskFix)
59
- fixes.push(taskFix);
60
- fixes.push(...(await fixUnregisteredOutboxEntries(targetDir, files, drift, config)));
61
- const listenerFix = await fixUnregisteredListeners(targetDir, files, drift, config);
88
+ if (config.paths.schedules === config.paths.tasks) {
89
+ const workflowFix = await planCoLocatedScheduleAndTaskRegistries(targetDir, files, drift, config);
90
+ if (workflowFix)
91
+ operations.push(workflowFix);
92
+ }
93
+ else {
94
+ const scheduleFix = await planUnregisteredSchedules(targetDir, files, drift, config);
95
+ if (scheduleFix)
96
+ operations.push(scheduleFix);
97
+ const taskFix = await planUnregisteredTasks(targetDir, files, drift, config);
98
+ if (taskFix)
99
+ operations.push(taskFix);
100
+ }
101
+ const outboxFix = await planUnregisteredOutboxEntries(targetDir, files, drift, config);
102
+ if (outboxFix)
103
+ operations.push(outboxFix);
104
+ const listenerFix = await planUnregisteredListeners(targetDir, files, drift, config);
62
105
  if (listenerFix)
63
- fixes.push(listenerFix);
64
- const openApiFix = await fixDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes);
106
+ operations.push(listenerFix);
107
+ const openApiFix = await planDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes);
65
108
  if (openApiFix)
66
- fixes.push(openApiFix);
67
- return fixes;
109
+ operations.push(openApiFix);
110
+ return createDoctorFixPlan({
111
+ targetDir,
112
+ strict: Boolean(options.strict),
113
+ operations,
114
+ });
68
115
  }
69
116
  /**
70
117
  * Format inspected routes as a CLI table.
@@ -88,10 +135,54 @@ export function formatRoutes(result) {
88
135
  ...rows.map((row) => [row.method, row.path, row.contract, row.handler]),
89
136
  ]);
90
137
  }
138
+ /**
139
+ * Format a read-only doctor fix plan with exact patches and apply commands.
140
+ */
141
+ export function formatDoctorFixPlan(plan, options = {}) {
142
+ const paint = createPainter(options.color);
143
+ const lines = [];
144
+ if (plan.operations.length === 0) {
145
+ lines.push(`No applicable Beignet fixes found in ${plan.targetDir}.`);
146
+ }
147
+ else {
148
+ const strictFlag = plan.strict ? " --strict" : "";
149
+ lines.push(`Planned ${plan.operations.length} Beignet fix operation${plan.operations.length === 1 ? "" : "s"} in ${plan.targetDir}:`, "");
150
+ for (const operation of plan.operations) {
151
+ lines.push(`${paint("PLAN", "cyan")} ${operation.id}`, ...operation.fixes.map((fix) => ` ${fix.code} ${fix.file}\n ${fix.message}`), ...operation.changes.flatMap((change) => [
152
+ ` ${change.kind === "create" ? "Create" : "Update"} ${change.file}`,
153
+ change.patch.trimEnd(),
154
+ ]), "");
155
+ }
156
+ lines.push(`Plan ID: ${plan.planId}`, "", "Apply the complete plan:", ` beignet doctor --fix${strictFlag} --plan ${plan.planId}`, "", "Apply selected operations:", ` beignet doctor --fix${strictFlag} --plan ${plan.planId} --only ${plan.operations.map((operation) => operation.id).join(",")}`);
157
+ }
158
+ lines.push("", formatDoctorResult({
159
+ targetDir: plan.targetDir,
160
+ strict: plan.strict,
161
+ diagnostics: plan.diagnostics,
162
+ fixes: [],
163
+ }, options));
164
+ return lines.join("\n");
165
+ }
166
+ /**
167
+ * Format planned fixes as GitHub Actions notice annotations.
168
+ */
169
+ export function formatDoctorFixPlanGithub(plan) {
170
+ return [
171
+ ...plan.operations.map((operation) => formatGithubAnnotation({
172
+ severity: "notice",
173
+ file: operation.changes[0]?.file,
174
+ message: `${operation.id}: ${operation.fixes.map((fix) => fix.message).join(" ")} Plan ${plan.planId}.`,
175
+ })),
176
+ ...formatDoctorDiagnosticsGithub(plan.diagnostics),
177
+ ].join("\n");
178
+ }
91
179
  /**
92
180
  * Format doctor diagnostics and applied fixes for CLI output.
93
181
  */
94
182
  export function formatDoctor(result, options = {}) {
183
+ return formatDoctorResult(result, options);
184
+ }
185
+ function formatDoctorResult(result, options = {}) {
95
186
  const paint = createPainter(options.color);
96
187
  const fixLines = result.fixes.length === 0
97
188
  ? []
@@ -193,13 +284,14 @@ function formatDoctorFooter(result) {
193
284
  * Format doctor diagnostics as GitHub Actions annotations.
194
285
  */
195
286
  export function formatDoctorGithub(result) {
196
- return result.diagnostics
197
- .map((diagnostic) => formatGithubAnnotation({
287
+ return formatDoctorDiagnosticsGithub(result.diagnostics).join("\n");
288
+ }
289
+ function formatDoctorDiagnosticsGithub(diagnostics) {
290
+ return diagnostics.map((diagnostic) => formatGithubAnnotation({
198
291
  severity: githubAnnotationSeverity(diagnostic.severity),
199
292
  file: diagnostic.file,
200
293
  message: `${diagnostic.code}: ${diagnostic.message}`,
201
- }))
202
- .join("\n");
294
+ }));
203
295
  }
204
296
  function githubAnnotationSeverity(severity) {
205
297
  if (severity === "hint")
@@ -3724,7 +3816,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
3724
3816
  severity: "warning",
3725
3817
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
3726
3818
  file: "package.json",
3727
- message: 'package.json does not define a test script. Add "test": "tsx lib/beignet-test-runner.ts" or run beignet doctor --fix.',
3819
+ message: `package.json does not define a test script. Add "test": "${currentGeneratedPackageScripts.test}" or run beignet doctor --fix.`,
3728
3820
  });
3729
3821
  }
3730
3822
  const legacyScripts = hasDirectBunTestImports
@@ -3735,7 +3827,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
3735
3827
  severity: "warning",
3736
3828
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
3737
3829
  file: "package.json",
3738
- 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.`,
3830
+ 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.`,
3739
3831
  });
3740
3832
  }
3741
3833
  const needsTsx = hasCurrentGeneratedTsxScript(scripts) &&
@@ -3761,7 +3853,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
3761
3853
  }
3762
3854
  return diagnostics;
3763
3855
  }
3764
- async function fixPackageScripts(targetDir, files, convention) {
3856
+ async function planPackageScripts(targetDir, files, convention) {
3765
3857
  if (!convention.resourceGenerator || !files.includes("package.json")) {
3766
3858
  return undefined;
3767
3859
  }
@@ -3777,8 +3869,8 @@ async function fixPackageScripts(targetDir, files, convention) {
3777
3869
  updatedScripts.push("test");
3778
3870
  }
3779
3871
  if (!hasDirectBunTestImports) {
3780
- for (const [name, legacyCommand] of Object.entries(legacyGeneratedPackageScripts)) {
3781
- if (packageJson.scripts[name] === legacyCommand) {
3872
+ for (const name of Object.keys(legacyGeneratedPackageScripts)) {
3873
+ if (isLegacyGeneratedPackageScript(name, packageJson.scripts[name])) {
3782
3874
  packageJson.scripts[name] = currentGeneratedPackageScripts[name];
3783
3875
  if (!updatedScripts.includes(name))
3784
3876
  updatedScripts.push(name);
@@ -3794,47 +3886,65 @@ async function fixPackageScripts(targetDir, files, convention) {
3794
3886
  packageJson.devDependencies.tsx = externalVersions.tsx;
3795
3887
  }
3796
3888
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
3797
- const createdSupportFiles = await createMissingTestSupportFiles(targetDir, files, packageJson.scripts.test === currentGeneratedPackageScripts.test);
3889
+ const createdSupportFiles = packageJson.scripts.test === currentGeneratedPackageScripts.test
3890
+ ? missingTestSupportFiles(files)
3891
+ : [];
3798
3892
  if (next === original && createdSupportFiles.length === 0)
3799
3893
  return undefined;
3800
- if (next !== original)
3801
- await writeFile(filePath, next);
3894
+ let fix;
3802
3895
  if (addedTestScript) {
3803
- return {
3896
+ fix = {
3804
3897
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
3805
3898
  file: "package.json",
3806
3899
  message: createdSupportFiles.length > 0
3807
- ? `Added "test": "tsx lib/beignet-test-runner.ts" and ${createdSupportFiles.join(", ")}.`
3808
- : 'Added "test": "tsx lib/beignet-test-runner.ts".',
3900
+ ? `Added "test": "${currentGeneratedPackageScripts.test}" and ${createdSupportFiles.join(", ")}.`
3901
+ : `Added "test": "${currentGeneratedPackageScripts.test}".`,
3809
3902
  };
3810
3903
  }
3811
- if (updatedScripts.length > 0) {
3812
- return {
3904
+ else if (updatedScripts.length > 0) {
3905
+ fix = {
3813
3906
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
3814
3907
  file: "package.json",
3815
3908
  message: packageScriptFixMessage(updatedScripts, createdSupportFiles, addedTsx),
3816
3909
  };
3817
3910
  }
3818
- if (createdSupportFiles.length > 0) {
3819
- return {
3911
+ else if (createdSupportFiles.length > 0) {
3912
+ fix = {
3820
3913
  code: "BEIGNET_PACKAGE_TEST_SUPPORT_MISSING",
3821
3914
  file: "package.json",
3822
3915
  message: `Restored ${createdSupportFiles.join(", ")}.`,
3823
3916
  };
3824
3917
  }
3825
- if (addedTsx) {
3826
- return {
3918
+ else if (addedTsx) {
3919
+ fix = {
3827
3920
  code: "BEIGNET_PACKAGE_TSX_DEPENDENCY_MISSING",
3828
3921
  file: "package.json",
3829
3922
  message: 'Added "tsx" to devDependencies.',
3830
3923
  };
3831
3924
  }
3832
- return undefined;
3925
+ if (!fix)
3926
+ return undefined;
3927
+ return {
3928
+ id: "package.repair-generated-support",
3929
+ fixes: [fix],
3930
+ changes: [
3931
+ ...(next === original
3932
+ ? []
3933
+ : [
3934
+ planDoctorFixFileChange({
3935
+ file: "package.json",
3936
+ before: original,
3937
+ after: next,
3938
+ }),
3939
+ ]),
3940
+ ...testSupportTemplateFiles
3941
+ .filter((file) => createdSupportFiles.includes(file.path))
3942
+ .map((file) => planDoctorFixFileChange({ file: file.path, after: file.content })),
3943
+ ],
3944
+ };
3833
3945
  }
3834
3946
  function legacyGeneratedScriptNames(scripts) {
3835
- return Object.entries(legacyGeneratedPackageScripts)
3836
- .filter(([name, command]) => scripts[name] === command)
3837
- .map(([name]) => name);
3947
+ return Object.keys(legacyGeneratedPackageScripts).filter((name) => isLegacyGeneratedPackageScript(name, scripts[name]));
3838
3948
  }
3839
3949
  async function appUsesDirectBunTestImports(targetDir, files) {
3840
3950
  for (const file of files) {
@@ -3849,27 +3959,14 @@ async function appUsesDirectBunTestImports(targetDir, files) {
3849
3959
  return false;
3850
3960
  }
3851
3961
  function hasCurrentGeneratedTsxScript(scripts) {
3852
- return Object.values(currentGeneratedPackageScripts).some((command) => command.startsWith("tsx ") && Object.values(scripts).includes(command));
3962
+ return Object.values(currentGeneratedPackageScripts).some((command) => (command.startsWith("tsx ") || command.includes("--import tsx")) &&
3963
+ Object.values(scripts).includes(command));
3853
3964
  }
3854
3965
  function missingTestSupportFiles(files) {
3855
3966
  return testSupportTemplateFiles
3856
3967
  .map((file) => file.path)
3857
3968
  .filter((file) => !files.includes(file));
3858
3969
  }
3859
- async function createMissingTestSupportFiles(targetDir, files, shouldCreate) {
3860
- if (!shouldCreate)
3861
- return [];
3862
- const createdSupportFiles = [];
3863
- for (const file of testSupportTemplateFiles) {
3864
- if (files.includes(file.path))
3865
- continue;
3866
- const destination = path.join(targetDir, file.path);
3867
- await mkdir(path.dirname(destination), { recursive: true });
3868
- await writeFile(destination, file.content);
3869
- createdSupportFiles.push(file.path);
3870
- }
3871
- return createdSupportFiles;
3872
- }
3873
3970
  function packageScriptFixMessage(updatedScripts, createdSupportFiles, addedTsx) {
3874
3971
  const parts = [
3875
3972
  `Updated generated package scripts: ${updatedScripts.join(", ")}.`,
@@ -4207,7 +4304,7 @@ function findRouteGroupContract(contracts, routeGroupContract) {
4207
4304
  contract.file ===
4208
4305
  routeGroupContract.file.replace(/\.ts$/, "/index.ts")));
4209
4306
  }
4210
- async function fixDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
4307
+ async function planDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
4211
4308
  const routePath = path.join(targetDir, config.paths.openapiRoute);
4212
4309
  let source;
4213
4310
  try {
@@ -4231,16 +4328,27 @@ async function fixDirectOpenApiArrayDrift(targetDir, files, config, convention,
4231
4328
  const next = `${source.slice(0, firstArg.start)}${nextArray}${source.slice(firstArg.end)}`;
4232
4329
  if (next === source)
4233
4330
  return undefined;
4234
- await writeFile(routePath, next);
4235
4331
  return {
4236
- code: "BEIGNET_OPENAPI_MISSING",
4237
- file: config.paths.openapiRoute,
4238
- message: `Added ${missingContracts
4239
- .map((contract) => contract.exportName)
4240
- .join(", ")} to the direct createOpenAPIHandler contract array.`,
4332
+ id: "openapi.register-missing",
4333
+ fixes: [
4334
+ {
4335
+ code: "BEIGNET_OPENAPI_MISSING",
4336
+ file: config.paths.openapiRoute,
4337
+ message: `Added ${missingContracts
4338
+ .map((contract) => contract.exportName)
4339
+ .join(", ")} to the direct createOpenAPIHandler contract array.`,
4340
+ },
4341
+ ],
4342
+ changes: [
4343
+ planDoctorFixFileChange({
4344
+ file: config.paths.openapiRoute,
4345
+ before: source,
4346
+ after: next,
4347
+ }),
4348
+ ],
4241
4349
  };
4242
4350
  }
4243
- async function fixUnregisteredRouteGroups(targetDir, files, config) {
4351
+ async function planUnregisteredRouteGroups(targetDir, files, config) {
4244
4352
  if (!files.includes(config.paths.server))
4245
4353
  return undefined;
4246
4354
  const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
@@ -4283,19 +4391,30 @@ async function fixUnregisteredRouteGroups(targetDir, files, config) {
4283
4391
  next = `${next.slice(0, nextFirstArg.start)}${nextArray}${next.slice(nextFirstArg.end)}`;
4284
4392
  if (next === original)
4285
4393
  return undefined;
4286
- await writeFile(targetPath, next);
4287
4394
  return {
4288
- code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
4289
- file: targetFile,
4290
- message: `Registered ${groupsToAppend
4291
- .map((routeGroup) => routeGroup.name)
4292
- .join(", ")} in the central route list.`,
4395
+ id: "routes.register-missing",
4396
+ fixes: [
4397
+ {
4398
+ code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
4399
+ file: targetFile,
4400
+ message: `Registered ${groupsToAppend
4401
+ .map((routeGroup) => routeGroup.name)
4402
+ .join(", ")} in the central route list.`,
4403
+ },
4404
+ ],
4405
+ changes: [
4406
+ planDoctorFixFileChange({
4407
+ file: targetFile,
4408
+ before: original,
4409
+ after: next,
4410
+ }),
4411
+ ],
4293
4412
  };
4294
4413
  }
4295
- async function fixUnregisteredSchedules(targetDir, files, drift, config) {
4414
+ async function planUnregisteredSchedules(targetDir, files, drift, config) {
4296
4415
  if (!drift.schedules.centralExists)
4297
4416
  return undefined;
4298
- return applyWorkflowRegistryFix({
4417
+ return planWorkflowRegistryOperation("schedules.register-missing", {
4299
4418
  targetDir,
4300
4419
  files,
4301
4420
  centralFile: config.paths.schedules,
@@ -4306,10 +4425,10 @@ async function fixUnregisteredSchedules(targetDir, files, drift, config) {
4306
4425
  append: (source, entry, importLine) => appendToNamedArray(source, "schedules", entry, importLine),
4307
4426
  });
4308
4427
  }
4309
- async function fixUnregisteredTasks(targetDir, files, drift, config) {
4428
+ async function planUnregisteredTasks(targetDir, files, drift, config) {
4310
4429
  if (!drift.tasks.centralExists)
4311
4430
  return undefined;
4312
- return applyWorkflowRegistryFix({
4431
+ return planWorkflowRegistryOperation("tasks.register-missing", {
4313
4432
  targetDir,
4314
4433
  files,
4315
4434
  centralFile: config.paths.tasks,
@@ -4320,6 +4439,70 @@ async function fixUnregisteredTasks(targetDir, files, drift, config) {
4320
4439
  append: (source, entry, importLine) => appendToNamedArray(source, "tasks", entry, importLine),
4321
4440
  });
4322
4441
  }
4442
+ async function planCoLocatedScheduleAndTaskRegistries(targetDir, files, drift, config) {
4443
+ if (!drift.schedules.centralExists || !drift.tasks.centralExists) {
4444
+ return undefined;
4445
+ }
4446
+ const centralFile = config.paths.schedules;
4447
+ let original;
4448
+ try {
4449
+ original = await readFile(path.join(targetDir, centralFile), "utf8");
4450
+ }
4451
+ catch {
4452
+ return undefined;
4453
+ }
4454
+ let next = original;
4455
+ const fixes = [];
4456
+ const repairedKinds = [];
4457
+ const scheduleEdit = planWorkflowRegistryEdit({
4458
+ files,
4459
+ centralFile,
4460
+ source: next,
4461
+ unregistered: drift.schedules.unregistered,
4462
+ code: "BEIGNET_SCHEDULE_UNREGISTERED",
4463
+ listName: "the schedules array",
4464
+ importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4465
+ append: (source, entry, importLine) => appendToNamedArray(source, "schedules", entry, importLine),
4466
+ });
4467
+ if (scheduleEdit) {
4468
+ next = scheduleEdit.after;
4469
+ fixes.push(scheduleEdit.fix);
4470
+ repairedKinds.push("schedules");
4471
+ }
4472
+ const taskEdit = planWorkflowRegistryEdit({
4473
+ files,
4474
+ centralFile,
4475
+ source: next,
4476
+ unregistered: drift.tasks.unregistered,
4477
+ code: "BEIGNET_TASK_UNREGISTERED",
4478
+ listName: "defineTasks([...])",
4479
+ importSpecifier: (indexFile) => relativeModule(centralFile, indexFile),
4480
+ append: (source, entry, importLine) => appendToNamedArray(source, "tasks", entry, importLine),
4481
+ });
4482
+ if (taskEdit) {
4483
+ next = taskEdit.after;
4484
+ fixes.push(taskEdit.fix);
4485
+ repairedKinds.push("tasks");
4486
+ }
4487
+ if (fixes.length === 0 || next === original)
4488
+ return undefined;
4489
+ const id = repairedKinds.length === 2
4490
+ ? "workflows.register-missing"
4491
+ : repairedKinds[0] === "schedules"
4492
+ ? "schedules.register-missing"
4493
+ : "tasks.register-missing";
4494
+ return {
4495
+ id,
4496
+ fixes,
4497
+ changes: [
4498
+ planDoctorFixFileChange({
4499
+ file: centralFile,
4500
+ before: original,
4501
+ after: next,
4502
+ }),
4503
+ ],
4504
+ };
4505
+ }
4323
4506
  /**
4324
4507
  * Wire fully unregistered feature listener registries into the central
4325
4508
  * listener registry and ensure the providers file registers that central
@@ -4331,7 +4514,7 @@ async function fixUnregisteredTasks(targetDir, files, drift, config) {
4331
4514
  * register against, or when the registry name is already imported from another
4332
4515
  * file. The diagnostic stays in every bail case.
4333
4516
  */
4334
- async function fixUnregisteredListeners(targetDir, files, drift, config) {
4517
+ async function planUnregisteredListeners(targetDir, files, drift, config) {
4335
4518
  const candidates = drift.listeners.unregistered.filter((entry) => entry.fullyUnregistered);
4336
4519
  if (candidates.length === 0)
4337
4520
  return undefined;
@@ -4407,60 +4590,93 @@ async function fixUnregisteredListeners(targetDir, files, drift, config) {
4407
4590
  nextProviders === originalProviders) {
4408
4591
  return undefined;
4409
4592
  }
4410
- await mkdir(path.dirname(listenersPath), { recursive: true });
4411
- await writeFile(listenersPath, nextListeners);
4412
- if (nextProviders !== originalProviders) {
4413
- await writeFile(providersPath, nextProviders);
4414
- }
4415
4593
  return {
4416
- code: "BEIGNET_LISTENER_UNREGISTERED",
4417
- file: listenersFile,
4418
- message: `Registered ${candidateNames.join(", ")} in ${listenersFile} and wired listeners with registerListeners(...) in ${providersFile}.`,
4594
+ id: "listeners.register-missing",
4595
+ fixes: [
4596
+ {
4597
+ code: "BEIGNET_LISTENER_UNREGISTERED",
4598
+ file: listenersFile,
4599
+ message: `Registered ${candidateNames.join(", ")} in ${listenersFile} and wired listeners with registerListeners(...) in ${providersFile}.`,
4600
+ },
4601
+ ],
4602
+ changes: [
4603
+ ...(nextListeners === originalListeners
4604
+ ? []
4605
+ : [
4606
+ planDoctorFixFileChange({
4607
+ file: listenersFile,
4608
+ before: originalListeners,
4609
+ after: nextListeners,
4610
+ }),
4611
+ ]),
4612
+ ...(nextProviders === originalProviders
4613
+ ? []
4614
+ : [
4615
+ planDoctorFixFileChange({
4616
+ file: providersFile,
4617
+ before: originalProviders,
4618
+ after: nextProviders,
4619
+ }),
4620
+ ]),
4621
+ ],
4419
4622
  };
4420
4623
  }
4421
- async function fixUnregisteredOutboxEntries(targetDir, files, drift, config) {
4624
+ async function planUnregisteredOutboxEntries(targetDir, files, drift, config) {
4422
4625
  if (!drift.outbox.exists)
4423
- return [];
4626
+ return undefined;
4627
+ const outboxPath = path.join(targetDir, config.paths.outbox);
4628
+ let original;
4629
+ try {
4630
+ original = await readFile(outboxPath, "utf8");
4631
+ }
4632
+ catch {
4633
+ return undefined;
4634
+ }
4635
+ let next = original;
4424
4636
  const fixes = [];
4425
- const eventFix = await applyWorkflowRegistryFix({
4426
- targetDir,
4637
+ const eventFix = planWorkflowRegistryEdit({
4427
4638
  files,
4428
4639
  centralFile: config.paths.outbox,
4640
+ source: next,
4429
4641
  unregistered: drift.outbox.events,
4430
4642
  code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
4431
4643
  listName: "the outbox events list",
4432
4644
  importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4433
4645
  append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "events", entry, importLine),
4434
4646
  });
4435
- if (eventFix)
4436
- fixes.push(eventFix);
4437
- const jobFix = await applyWorkflowRegistryFix({
4438
- targetDir,
4647
+ if (eventFix) {
4648
+ next = eventFix.after;
4649
+ fixes.push(eventFix.fix);
4650
+ }
4651
+ const jobFix = planWorkflowRegistryEdit({
4439
4652
  files,
4440
4653
  centralFile: config.paths.outbox,
4654
+ source: next,
4441
4655
  unregistered: drift.outbox.jobs,
4442
4656
  code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
4443
4657
  listName: "the outbox jobs list",
4444
4658
  importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4445
4659
  append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "jobs", entry, importLine),
4446
4660
  });
4447
- if (jobFix)
4448
- fixes.push(jobFix);
4449
- return fixes;
4450
- }
4451
- /**
4452
- * Append `...<feature><Kind>` registry spreads to an existing central
4453
- * registry file.
4454
- *
4455
- * Bails without writing when an import for the registry name resolves to a
4456
- * different file, when the registry anchor is missing or unparseable, or when
4457
- * some members are already individually registered (appending the registry
4458
- * spread would run those members twice). The diagnostic stays in that case.
4459
- */
4460
- async function applyWorkflowRegistryFix(options) {
4461
- const candidates = options.unregistered.filter((entry) => entry.fullyUnregistered);
4462
- if (candidates.length === 0)
4661
+ if (jobFix) {
4662
+ next = jobFix.after;
4663
+ fixes.push(jobFix.fix);
4664
+ }
4665
+ if (fixes.length === 0 || next === original)
4463
4666
  return undefined;
4667
+ return {
4668
+ id: "outbox.register-missing",
4669
+ fixes,
4670
+ changes: [
4671
+ planDoctorFixFileChange({
4672
+ file: config.paths.outbox,
4673
+ before: original,
4674
+ after: next,
4675
+ }),
4676
+ ],
4677
+ };
4678
+ }
4679
+ async function planWorkflowRegistryOperation(id, options) {
4464
4680
  const centralPath = path.join(options.targetDir, options.centralFile);
4465
4681
  let original;
4466
4682
  try {
@@ -4469,7 +4685,26 @@ async function applyWorkflowRegistryFix(options) {
4469
4685
  catch {
4470
4686
  return undefined;
4471
4687
  }
4472
- let next = original;
4688
+ const edit = planWorkflowRegistryEdit({ ...options, source: original });
4689
+ if (!edit)
4690
+ return undefined;
4691
+ return {
4692
+ id,
4693
+ fixes: [edit.fix],
4694
+ changes: [
4695
+ planDoctorFixFileChange({
4696
+ file: options.centralFile,
4697
+ before: original,
4698
+ after: edit.after,
4699
+ }),
4700
+ ],
4701
+ };
4702
+ }
4703
+ function planWorkflowRegistryEdit(options) {
4704
+ const candidates = options.unregistered.filter((entry) => entry.fullyUnregistered);
4705
+ if (candidates.length === 0)
4706
+ return undefined;
4707
+ let next = options.source;
4473
4708
  const registeredNames = [];
4474
4709
  for (const { registry } of candidates) {
4475
4710
  const imported = parseNamedImportSources(next).get(registry.registryName);
@@ -4490,13 +4725,15 @@ async function applyWorkflowRegistryFix(options) {
4490
4725
  registeredNames.push(registry.registryName);
4491
4726
  }
4492
4727
  }
4493
- if (next === original || registeredNames.length === 0)
4728
+ if (next === options.source || registeredNames.length === 0)
4494
4729
  return undefined;
4495
- await writeFile(centralPath, next);
4496
4730
  return {
4497
- code: options.code,
4498
- file: options.centralFile,
4499
- message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
4731
+ after: next,
4732
+ fix: {
4733
+ code: options.code,
4734
+ file: options.centralFile,
4735
+ message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
4736
+ },
4500
4737
  };
4501
4738
  }
4502
4739
  function routeRegistryFileFromServerSource(source, serverFile, files) {