@beignet/cli 0.0.40 → 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 (111) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +139 -17
  3. package/dist/analysis/source-index.d.ts +38 -0
  4. package/dist/analysis/source-index.d.ts.map +1 -0
  5. package/dist/analysis/source-index.js +271 -0
  6. package/dist/analysis/source-index.js.map +1 -0
  7. package/dist/analysis/workspace.d.ts +16 -0
  8. package/dist/analysis/workspace.d.ts.map +1 -0
  9. package/dist/analysis/workspace.js +134 -0
  10. package/dist/analysis/workspace.js.map +1 -0
  11. package/dist/app-map-schema.d.ts +5 -0
  12. package/dist/app-map-schema.d.ts.map +1 -0
  13. package/dist/app-map-schema.js +26 -0
  14. package/dist/app-map-schema.js.map +1 -0
  15. package/dist/app-map.d.ts +96 -0
  16. package/dist/app-map.d.ts.map +1 -0
  17. package/dist/app-map.js +1141 -0
  18. package/dist/app-map.js.map +1 -0
  19. package/dist/check.d.ts +7 -0
  20. package/dist/check.d.ts.map +1 -1
  21. package/dist/check.js +20 -5
  22. package/dist/check.js.map +1 -1
  23. package/dist/choices.d.ts +18 -0
  24. package/dist/choices.d.ts.map +1 -1
  25. package/dist/choices.js +35 -0
  26. package/dist/choices.js.map +1 -1
  27. package/dist/db.d.ts +49 -13
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +202 -21
  30. package/dist/db.js.map +1 -1
  31. package/dist/doctor-fixes.d.ts +64 -0
  32. package/dist/doctor-fixes.d.ts.map +1 -0
  33. package/dist/doctor-fixes.js +142 -0
  34. package/dist/doctor-fixes.js.map +1 -0
  35. package/dist/explain.d.ts +100 -0
  36. package/dist/explain.d.ts.map +1 -0
  37. package/dist/explain.js +606 -0
  38. package/dist/explain.js.map +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +165 -21
  41. package/dist/index.js.map +1 -1
  42. package/dist/inspect.d.ts +33 -9
  43. package/dist/inspect.d.ts.map +1 -1
  44. package/dist/inspect.js +406 -119
  45. package/dist/inspect.js.map +1 -1
  46. package/dist/lib.d.ts +10 -2
  47. package/dist/lib.d.ts.map +1 -1
  48. package/dist/lib.js +5 -2
  49. package/dist/lib.js.map +1 -1
  50. package/dist/make/shared.js +3 -3
  51. package/dist/make/shared.js.map +1 -1
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +63 -2
  54. package/dist/make.js.map +1 -1
  55. package/dist/mcp.d.ts +1 -1
  56. package/dist/mcp.d.ts.map +1 -1
  57. package/dist/mcp.js +188 -12
  58. package/dist/mcp.js.map +1 -1
  59. package/dist/preflight.d.ts.map +1 -1
  60. package/dist/preflight.js +10 -0
  61. package/dist/preflight.js.map +1 -1
  62. package/dist/provider-add.d.ts.map +1 -1
  63. package/dist/provider-add.js +167 -13
  64. package/dist/provider-add.js.map +1 -1
  65. package/dist/provider-audit.d.ts +6 -0
  66. package/dist/provider-audit.d.ts.map +1 -1
  67. package/dist/provider-audit.js +63 -15
  68. package/dist/provider-audit.js.map +1 -1
  69. package/dist/templates/agents.d.ts.map +1 -1
  70. package/dist/templates/agents.js +35 -6
  71. package/dist/templates/agents.js.map +1 -1
  72. package/dist/templates/base.d.ts.map +1 -1
  73. package/dist/templates/base.js +13 -7
  74. package/dist/templates/base.js.map +1 -1
  75. package/dist/templates/index.d.ts +1 -1
  76. package/dist/templates/index.d.ts.map +1 -1
  77. package/dist/templates/index.js +8 -1
  78. package/dist/templates/index.js.map +1 -1
  79. package/dist/templates/server.d.ts +3 -0
  80. package/dist/templates/server.d.ts.map +1 -1
  81. package/dist/templates/server.js +55 -1
  82. package/dist/templates/server.js.map +1 -1
  83. package/dist/templates/shared.d.ts +2 -1
  84. package/dist/templates/shared.d.ts.map +1 -1
  85. package/dist/templates/shared.js +8 -5
  86. package/dist/templates/shared.js.map +1 -1
  87. package/package.json +3 -2
  88. package/skills/app-structure/SKILL.md +48 -6
  89. package/src/analysis/source-index.ts +395 -0
  90. package/src/analysis/workspace.ts +180 -0
  91. package/src/app-map-schema.ts +28 -0
  92. package/src/app-map.ts +1705 -0
  93. package/src/check.ts +27 -4
  94. package/src/choices.ts +57 -0
  95. package/src/db.ts +276 -28
  96. package/src/doctor-fixes.ts +252 -0
  97. package/src/explain.ts +894 -0
  98. package/src/index.ts +237 -31
  99. package/src/inspect.ts +567 -147
  100. package/src/lib.ts +64 -1
  101. package/src/make/shared.ts +3 -3
  102. package/src/make.ts +90 -2
  103. package/src/mcp.ts +292 -12
  104. package/src/preflight.ts +14 -0
  105. package/src/provider-add.ts +211 -12
  106. package/src/provider-audit.ts +127 -22
  107. package/src/templates/agents.ts +35 -6
  108. package/src/templates/base.ts +25 -6
  109. package/src/templates/index.ts +18 -1
  110. package/src/templates/server.ts +58 -1
  111. package/src/templates/shared.ts +15 -7
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")
@@ -308,7 +400,7 @@ function parseContracts(source, file) {
308
400
  const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=([\s\S]*?)(?=\nexport const\s+[A-Za-z_$][\w$]*\s*=|$)/g;
309
401
  for (const exportMatch of source.matchAll(exportRegex)) {
310
402
  const block = exportMatch[2];
311
- const methodMatch = /([A-Za-z_$][\w$]*)(?:\s*\.\s*[A-Za-z_$][\w$]*\([^)]*\))*\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["']\s*\)/.exec(block);
403
+ const methodMatch = /([A-Za-z_$][\w$]*)(?:\s*\.\s*[A-Za-z_$][\w$]*\([^)]*\))*\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["'](?:\s*,\s*["'][^"']+["'])?\s*\)/.exec(block);
312
404
  if (methodMatch) {
313
405
  const receiver = methodMatch[1];
314
406
  const pathPrefix = groupPrefixes.get(receiver) ?? "";
@@ -911,7 +1003,7 @@ async function inspectDiagnostics(targetDir, files, config, convention, contract
911
1003
  diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
912
1004
  }
913
1005
  }
914
- diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict)), ...(await inspectCanonicalConformance(targetDir, files, config, convention, strict)), ...(await inspectVersionSkew(targetDir, files)), ...(await inspectProviderMetadataDrift(targetDir, files, convention)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectProviderRegistrationDrift(targetDir, files, config, convention)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectAuthzAuditDrift(targetDir, files, config, convention, contracts)), ...(await inspectServerlessFootguns(targetDir, files, config, convention)), ...(await inspectProductionReadiness(targetDir, files, config, convention)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectWorkflowRegistrationDrift(targetDir, files, config, convention)), ...(await inspectRuntimeManifestDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
1006
+ diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict)), ...(await inspectCanonicalConformance(targetDir, files, config, convention, strict)), ...(await inspectVersionSkew(targetDir, files)), ...(await inspectProviderMetadataDrift(targetDir, files, convention)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectProviderRegistrationDrift(targetDir, files, config, convention)), ...(await inspectInngestProductionPath(targetDir, files, config)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectAuthzAuditDrift(targetDir, files, config, convention, contracts)), ...(await inspectServerlessFootguns(targetDir, files, config, convention)), ...(await inspectProductionReadiness(targetDir, files, config, convention)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectWorkflowRegistrationDrift(targetDir, files, config, convention)), ...(await inspectRuntimeManifestDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
915
1007
  return dedupeDiagnostics(diagnostics);
916
1008
  }
917
1009
  async function inspectFrameworkAdapter(targetDir, files, config) {
@@ -1900,6 +1992,56 @@ async function inspectProviderRegistrationDrift(targetDir, files, config, conven
1900
1992
  }
1901
1993
  return diagnostics;
1902
1994
  }
1995
+ async function inspectInngestProductionPath(targetDir, files, config) {
1996
+ const packageJson = await readPackageJson(targetDir, files);
1997
+ if (!installedPackageNames(packageJson).has("@beignet/provider-jobs-inngest")) {
1998
+ return [];
1999
+ }
2000
+ const diagnostics = [];
2001
+ const serverDir = directoryPath(path.dirname(config.paths.server));
2002
+ const registryFile = `${serverDir}/inngest.ts`;
2003
+ const routeFile = `${directoryPath(config.paths.routes)}/inngest/route.ts`;
2004
+ if (!files.includes(registryFile)) {
2005
+ diagnostics.push({
2006
+ severity: "warning",
2007
+ code: "BEIGNET_INNGEST_REGISTRY_MISSING",
2008
+ file: registryFile,
2009
+ message: `@beignet/provider-jobs-inngest is installed, but ${registryFile} is missing. Run beignet provider add jobs-inngest or create a central createInngestJobFunctions(...) registry so dispatched jobs can execute.`,
2010
+ });
2011
+ }
2012
+ else {
2013
+ const registrySource = await readFile(path.join(targetDir, registryFile), "utf8");
2014
+ const registry = arrayInitializerInfo(registrySource, "inngestJobs");
2015
+ const registeredJobs = registry
2016
+ ? identifiersFromArrayExpression(registry.text)
2017
+ : new Set();
2018
+ const featureRoot = directoryPath(config.paths.features);
2019
+ const jobIndexes = files.filter((file) => new RegExp(`^${escapeRegExp(featureRoot)}/[^/]+/jobs/index\\.ts$`).test(file));
2020
+ for (const jobIndex of jobIndexes) {
2021
+ const source = await readFile(path.join(targetDir, jobIndex), "utf8");
2022
+ const match = source.match(/export const\s+([A-Za-z_$][\w$]*Jobs)\s*=/);
2023
+ const registryName = match?.[1];
2024
+ if (!registryName || registeredJobs.has(registryName)) {
2025
+ continue;
2026
+ }
2027
+ diagnostics.push({
2028
+ severity: "warning",
2029
+ code: "BEIGNET_INNGEST_JOB_REGISTRATION_MISSING",
2030
+ file: registryFile,
2031
+ message: `${jobIndex} exports ${registryName}, but it is not registered in inngestJobs in ${registryFile}. Add ...${registryName} so Inngest can execute the job.`,
2032
+ });
2033
+ }
2034
+ }
2035
+ if (config.framework === "next" && !files.includes(routeFile)) {
2036
+ diagnostics.push({
2037
+ severity: "warning",
2038
+ code: "BEIGNET_INNGEST_ROUTE_MISSING",
2039
+ file: routeFile,
2040
+ message: `@beignet/provider-jobs-inngest is installed in a Next.js app, but ${routeFile} is missing. Expose the shared Inngest client and function registry with serve(...) so Inngest can discover and invoke jobs.`,
2041
+ });
2042
+ }
2043
+ return diagnostics;
2044
+ }
1903
2045
  function isFeatureUploadDefinition(file, config) {
1904
2046
  const featuresPath = directoryPath(config.paths.features);
1905
2047
  return new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/uploads/[^/]+\\.ts$`).test(file);
@@ -2672,7 +2814,7 @@ function callReferencesCentralListenerRegistry(args) {
2672
2814
  }
2673
2815
  for (const identifier of args.callIdentifiers) {
2674
2816
  const imported = args.namedImports.get(identifier);
2675
- if (!imported || imported.importedName !== "listeners")
2817
+ if (imported?.importedName !== "listeners")
2676
2818
  continue;
2677
2819
  const importedFile = sourceFileFromImport(imported.sourcePath, args.importerFile, args.files);
2678
2820
  if (importedFile === args.listenerRegistryFile)
@@ -3674,7 +3816,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
3674
3816
  severity: "warning",
3675
3817
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
3676
3818
  file: "package.json",
3677
- 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.`,
3678
3820
  });
3679
3821
  }
3680
3822
  const legacyScripts = hasDirectBunTestImports
@@ -3685,7 +3827,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
3685
3827
  severity: "warning",
3686
3828
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
3687
3829
  file: "package.json",
3688
- 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.`,
3689
3831
  });
3690
3832
  }
3691
3833
  const needsTsx = hasCurrentGeneratedTsxScript(scripts) &&
@@ -3711,7 +3853,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
3711
3853
  }
3712
3854
  return diagnostics;
3713
3855
  }
3714
- async function fixPackageScripts(targetDir, files, convention) {
3856
+ async function planPackageScripts(targetDir, files, convention) {
3715
3857
  if (!convention.resourceGenerator || !files.includes("package.json")) {
3716
3858
  return undefined;
3717
3859
  }
@@ -3727,8 +3869,8 @@ async function fixPackageScripts(targetDir, files, convention) {
3727
3869
  updatedScripts.push("test");
3728
3870
  }
3729
3871
  if (!hasDirectBunTestImports) {
3730
- for (const [name, legacyCommand] of Object.entries(legacyGeneratedPackageScripts)) {
3731
- if (packageJson.scripts[name] === legacyCommand) {
3872
+ for (const name of Object.keys(legacyGeneratedPackageScripts)) {
3873
+ if (isLegacyGeneratedPackageScript(name, packageJson.scripts[name])) {
3732
3874
  packageJson.scripts[name] = currentGeneratedPackageScripts[name];
3733
3875
  if (!updatedScripts.includes(name))
3734
3876
  updatedScripts.push(name);
@@ -3744,47 +3886,65 @@ async function fixPackageScripts(targetDir, files, convention) {
3744
3886
  packageJson.devDependencies.tsx = externalVersions.tsx;
3745
3887
  }
3746
3888
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
3747
- const createdSupportFiles = await createMissingTestSupportFiles(targetDir, files, packageJson.scripts.test === currentGeneratedPackageScripts.test);
3889
+ const createdSupportFiles = packageJson.scripts.test === currentGeneratedPackageScripts.test
3890
+ ? missingTestSupportFiles(files)
3891
+ : [];
3748
3892
  if (next === original && createdSupportFiles.length === 0)
3749
3893
  return undefined;
3750
- if (next !== original)
3751
- await writeFile(filePath, next);
3894
+ let fix;
3752
3895
  if (addedTestScript) {
3753
- return {
3896
+ fix = {
3754
3897
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
3755
3898
  file: "package.json",
3756
3899
  message: createdSupportFiles.length > 0
3757
- ? `Added "test": "tsx lib/beignet-test-runner.ts" and ${createdSupportFiles.join(", ")}.`
3758
- : 'Added "test": "tsx lib/beignet-test-runner.ts".',
3900
+ ? `Added "test": "${currentGeneratedPackageScripts.test}" and ${createdSupportFiles.join(", ")}.`
3901
+ : `Added "test": "${currentGeneratedPackageScripts.test}".`,
3759
3902
  };
3760
3903
  }
3761
- if (updatedScripts.length > 0) {
3762
- return {
3904
+ else if (updatedScripts.length > 0) {
3905
+ fix = {
3763
3906
  code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
3764
3907
  file: "package.json",
3765
3908
  message: packageScriptFixMessage(updatedScripts, createdSupportFiles, addedTsx),
3766
3909
  };
3767
3910
  }
3768
- if (createdSupportFiles.length > 0) {
3769
- return {
3911
+ else if (createdSupportFiles.length > 0) {
3912
+ fix = {
3770
3913
  code: "BEIGNET_PACKAGE_TEST_SUPPORT_MISSING",
3771
3914
  file: "package.json",
3772
3915
  message: `Restored ${createdSupportFiles.join(", ")}.`,
3773
3916
  };
3774
3917
  }
3775
- if (addedTsx) {
3776
- return {
3918
+ else if (addedTsx) {
3919
+ fix = {
3777
3920
  code: "BEIGNET_PACKAGE_TSX_DEPENDENCY_MISSING",
3778
3921
  file: "package.json",
3779
3922
  message: 'Added "tsx" to devDependencies.',
3780
3923
  };
3781
3924
  }
3782
- 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
+ };
3783
3945
  }
3784
3946
  function legacyGeneratedScriptNames(scripts) {
3785
- return Object.entries(legacyGeneratedPackageScripts)
3786
- .filter(([name, command]) => scripts[name] === command)
3787
- .map(([name]) => name);
3947
+ return Object.keys(legacyGeneratedPackageScripts).filter((name) => isLegacyGeneratedPackageScript(name, scripts[name]));
3788
3948
  }
3789
3949
  async function appUsesDirectBunTestImports(targetDir, files) {
3790
3950
  for (const file of files) {
@@ -3799,27 +3959,14 @@ async function appUsesDirectBunTestImports(targetDir, files) {
3799
3959
  return false;
3800
3960
  }
3801
3961
  function hasCurrentGeneratedTsxScript(scripts) {
3802
- 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));
3803
3964
  }
3804
3965
  function missingTestSupportFiles(files) {
3805
3966
  return testSupportTemplateFiles
3806
3967
  .map((file) => file.path)
3807
3968
  .filter((file) => !files.includes(file));
3808
3969
  }
3809
- async function createMissingTestSupportFiles(targetDir, files, shouldCreate) {
3810
- if (!shouldCreate)
3811
- return [];
3812
- const createdSupportFiles = [];
3813
- for (const file of testSupportTemplateFiles) {
3814
- if (files.includes(file.path))
3815
- continue;
3816
- const destination = path.join(targetDir, file.path);
3817
- await mkdir(path.dirname(destination), { recursive: true });
3818
- await writeFile(destination, file.content);
3819
- createdSupportFiles.push(file.path);
3820
- }
3821
- return createdSupportFiles;
3822
- }
3823
3970
  function packageScriptFixMessage(updatedScripts, createdSupportFiles, addedTsx) {
3824
3971
  const parts = [
3825
3972
  `Updated generated package scripts: ${updatedScripts.join(", ")}.`,
@@ -4157,7 +4304,7 @@ function findRouteGroupContract(contracts, routeGroupContract) {
4157
4304
  contract.file ===
4158
4305
  routeGroupContract.file.replace(/\.ts$/, "/index.ts")));
4159
4306
  }
4160
- async function fixDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
4307
+ async function planDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
4161
4308
  const routePath = path.join(targetDir, config.paths.openapiRoute);
4162
4309
  let source;
4163
4310
  try {
@@ -4181,16 +4328,27 @@ async function fixDirectOpenApiArrayDrift(targetDir, files, config, convention,
4181
4328
  const next = `${source.slice(0, firstArg.start)}${nextArray}${source.slice(firstArg.end)}`;
4182
4329
  if (next === source)
4183
4330
  return undefined;
4184
- await writeFile(routePath, next);
4185
4331
  return {
4186
- code: "BEIGNET_OPENAPI_MISSING",
4187
- file: config.paths.openapiRoute,
4188
- message: `Added ${missingContracts
4189
- .map((contract) => contract.exportName)
4190
- .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
+ ],
4191
4349
  };
4192
4350
  }
4193
- async function fixUnregisteredRouteGroups(targetDir, files, config) {
4351
+ async function planUnregisteredRouteGroups(targetDir, files, config) {
4194
4352
  if (!files.includes(config.paths.server))
4195
4353
  return undefined;
4196
4354
  const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
@@ -4233,19 +4391,30 @@ async function fixUnregisteredRouteGroups(targetDir, files, config) {
4233
4391
  next = `${next.slice(0, nextFirstArg.start)}${nextArray}${next.slice(nextFirstArg.end)}`;
4234
4392
  if (next === original)
4235
4393
  return undefined;
4236
- await writeFile(targetPath, next);
4237
4394
  return {
4238
- code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
4239
- file: targetFile,
4240
- message: `Registered ${groupsToAppend
4241
- .map((routeGroup) => routeGroup.name)
4242
- .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
+ ],
4243
4412
  };
4244
4413
  }
4245
- async function fixUnregisteredSchedules(targetDir, files, drift, config) {
4414
+ async function planUnregisteredSchedules(targetDir, files, drift, config) {
4246
4415
  if (!drift.schedules.centralExists)
4247
4416
  return undefined;
4248
- return applyWorkflowRegistryFix({
4417
+ return planWorkflowRegistryOperation("schedules.register-missing", {
4249
4418
  targetDir,
4250
4419
  files,
4251
4420
  centralFile: config.paths.schedules,
@@ -4256,10 +4425,10 @@ async function fixUnregisteredSchedules(targetDir, files, drift, config) {
4256
4425
  append: (source, entry, importLine) => appendToNamedArray(source, "schedules", entry, importLine),
4257
4426
  });
4258
4427
  }
4259
- async function fixUnregisteredTasks(targetDir, files, drift, config) {
4428
+ async function planUnregisteredTasks(targetDir, files, drift, config) {
4260
4429
  if (!drift.tasks.centralExists)
4261
4430
  return undefined;
4262
- return applyWorkflowRegistryFix({
4431
+ return planWorkflowRegistryOperation("tasks.register-missing", {
4263
4432
  targetDir,
4264
4433
  files,
4265
4434
  centralFile: config.paths.tasks,
@@ -4270,6 +4439,70 @@ async function fixUnregisteredTasks(targetDir, files, drift, config) {
4270
4439
  append: (source, entry, importLine) => appendToNamedArray(source, "tasks", entry, importLine),
4271
4440
  });
4272
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
+ }
4273
4506
  /**
4274
4507
  * Wire fully unregistered feature listener registries into the central
4275
4508
  * listener registry and ensure the providers file registers that central
@@ -4281,7 +4514,7 @@ async function fixUnregisteredTasks(targetDir, files, drift, config) {
4281
4514
  * register against, or when the registry name is already imported from another
4282
4515
  * file. The diagnostic stays in every bail case.
4283
4516
  */
4284
- async function fixUnregisteredListeners(targetDir, files, drift, config) {
4517
+ async function planUnregisteredListeners(targetDir, files, drift, config) {
4285
4518
  const candidates = drift.listeners.unregistered.filter((entry) => entry.fullyUnregistered);
4286
4519
  if (candidates.length === 0)
4287
4520
  return undefined;
@@ -4357,60 +4590,93 @@ async function fixUnregisteredListeners(targetDir, files, drift, config) {
4357
4590
  nextProviders === originalProviders) {
4358
4591
  return undefined;
4359
4592
  }
4360
- await mkdir(path.dirname(listenersPath), { recursive: true });
4361
- await writeFile(listenersPath, nextListeners);
4362
- if (nextProviders !== originalProviders) {
4363
- await writeFile(providersPath, nextProviders);
4364
- }
4365
4593
  return {
4366
- code: "BEIGNET_LISTENER_UNREGISTERED",
4367
- file: listenersFile,
4368
- 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
+ ],
4369
4622
  };
4370
4623
  }
4371
- async function fixUnregisteredOutboxEntries(targetDir, files, drift, config) {
4624
+ async function planUnregisteredOutboxEntries(targetDir, files, drift, config) {
4372
4625
  if (!drift.outbox.exists)
4373
- 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;
4374
4636
  const fixes = [];
4375
- const eventFix = await applyWorkflowRegistryFix({
4376
- targetDir,
4637
+ const eventFix = planWorkflowRegistryEdit({
4377
4638
  files,
4378
4639
  centralFile: config.paths.outbox,
4640
+ source: next,
4379
4641
  unregistered: drift.outbox.events,
4380
4642
  code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
4381
4643
  listName: "the outbox events list",
4382
4644
  importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4383
4645
  append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "events", entry, importLine),
4384
4646
  });
4385
- if (eventFix)
4386
- fixes.push(eventFix);
4387
- const jobFix = await applyWorkflowRegistryFix({
4388
- targetDir,
4647
+ if (eventFix) {
4648
+ next = eventFix.after;
4649
+ fixes.push(eventFix.fix);
4650
+ }
4651
+ const jobFix = planWorkflowRegistryEdit({
4389
4652
  files,
4390
4653
  centralFile: config.paths.outbox,
4654
+ source: next,
4391
4655
  unregistered: drift.outbox.jobs,
4392
4656
  code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
4393
4657
  listName: "the outbox jobs list",
4394
4658
  importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4395
4659
  append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "jobs", entry, importLine),
4396
4660
  });
4397
- if (jobFix)
4398
- fixes.push(jobFix);
4399
- return fixes;
4400
- }
4401
- /**
4402
- * Append `...<feature><Kind>` registry spreads to an existing central
4403
- * registry file.
4404
- *
4405
- * Bails without writing when an import for the registry name resolves to a
4406
- * different file, when the registry anchor is missing or unparseable, or when
4407
- * some members are already individually registered (appending the registry
4408
- * spread would run those members twice). The diagnostic stays in that case.
4409
- */
4410
- async function applyWorkflowRegistryFix(options) {
4411
- const candidates = options.unregistered.filter((entry) => entry.fullyUnregistered);
4412
- if (candidates.length === 0)
4661
+ if (jobFix) {
4662
+ next = jobFix.after;
4663
+ fixes.push(jobFix.fix);
4664
+ }
4665
+ if (fixes.length === 0 || next === original)
4413
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) {
4414
4680
  const centralPath = path.join(options.targetDir, options.centralFile);
4415
4681
  let original;
4416
4682
  try {
@@ -4419,7 +4685,26 @@ async function applyWorkflowRegistryFix(options) {
4419
4685
  catch {
4420
4686
  return undefined;
4421
4687
  }
4422
- 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;
4423
4708
  const registeredNames = [];
4424
4709
  for (const { registry } of candidates) {
4425
4710
  const imported = parseNamedImportSources(next).get(registry.registryName);
@@ -4440,13 +4725,15 @@ async function applyWorkflowRegistryFix(options) {
4440
4725
  registeredNames.push(registry.registryName);
4441
4726
  }
4442
4727
  }
4443
- if (next === original || registeredNames.length === 0)
4728
+ if (next === options.source || registeredNames.length === 0)
4444
4729
  return undefined;
4445
- await writeFile(centralPath, next);
4446
4730
  return {
4447
- code: options.code,
4448
- file: options.centralFile,
4449
- 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
+ },
4450
4737
  };
4451
4738
  }
4452
4739
  function routeRegistryFileFromServerSource(source, serverFile, files) {