@beignet/cli 0.0.44 → 0.0.46

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 (81) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +78 -16
  3. package/dist/choices.d.ts +5 -1
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +12 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/db.d.ts +4 -0
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +12 -6
  10. package/dist/db.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +13 -13
  13. package/dist/index.js.map +1 -1
  14. package/dist/inspect.d.ts.map +1 -1
  15. package/dist/inspect.js +598 -62
  16. package/dist/inspect.js.map +1 -1
  17. package/dist/lib.d.ts +4 -1
  18. package/dist/lib.d.ts.map +1 -1
  19. package/dist/lib.js +1 -1
  20. package/dist/lib.js.map +1 -1
  21. package/dist/mcp.d.ts +2 -1
  22. package/dist/mcp.d.ts.map +1 -1
  23. package/dist/mcp.js +356 -5
  24. package/dist/mcp.js.map +1 -1
  25. package/dist/operational-lifecycle.d.ts +12 -0
  26. package/dist/operational-lifecycle.d.ts.map +1 -0
  27. package/dist/operational-lifecycle.js +39 -0
  28. package/dist/operational-lifecycle.js.map +1 -0
  29. package/dist/operational-path.d.ts +3 -0
  30. package/dist/operational-path.d.ts.map +1 -0
  31. package/dist/operational-path.js +26 -0
  32. package/dist/operational-path.js.map +1 -0
  33. package/dist/operational-process.d.ts +78 -0
  34. package/dist/operational-process.d.ts.map +1 -0
  35. package/dist/operational-process.js +228 -0
  36. package/dist/operational-process.js.map +1 -0
  37. package/dist/operational-runner.d.ts +2 -0
  38. package/dist/operational-runner.d.ts.map +1 -0
  39. package/dist/operational-runner.js +168 -0
  40. package/dist/operational-runner.js.map +1 -0
  41. package/dist/outbox.d.ts +14 -6
  42. package/dist/outbox.d.ts.map +1 -1
  43. package/dist/outbox.js +140 -124
  44. package/dist/outbox.js.map +1 -1
  45. package/dist/schedule.d.ts +2 -1
  46. package/dist/schedule.d.ts.map +1 -1
  47. package/dist/schedule.js +79 -81
  48. package/dist/schedule.js.map +1 -1
  49. package/dist/task.d.ts +2 -1
  50. package/dist/task.d.ts.map +1 -1
  51. package/dist/task.js +57 -61
  52. package/dist/task.js.map +1 -1
  53. package/dist/templates/agents.d.ts.map +1 -1
  54. package/dist/templates/agents.js +32 -8
  55. package/dist/templates/agents.js.map +1 -1
  56. package/dist/templates/server.d.ts.map +1 -1
  57. package/dist/templates/server.js +9 -2
  58. package/dist/templates/server.js.map +1 -1
  59. package/dist/templates/shadcn.d.ts.map +1 -1
  60. package/dist/templates/shadcn.js +3 -1
  61. package/dist/templates/shadcn.js.map +1 -1
  62. package/dist/templates/shared.js +1 -1
  63. package/package.json +2 -2
  64. package/skills/app-structure/SKILL.md +26 -7
  65. package/src/choices.ts +22 -0
  66. package/src/db.ts +12 -6
  67. package/src/index.ts +19 -16
  68. package/src/inspect.ts +844 -67
  69. package/src/lib.ts +27 -1
  70. package/src/mcp.ts +459 -4
  71. package/src/operational-lifecycle.ts +56 -0
  72. package/src/operational-path.ts +35 -0
  73. package/src/operational-process.ts +454 -0
  74. package/src/operational-runner.ts +221 -0
  75. package/src/outbox.ts +154 -128
  76. package/src/schedule.ts +84 -89
  77. package/src/task.ts +58 -62
  78. package/src/templates/agents.ts +32 -8
  79. package/src/templates/server.ts +9 -2
  80. package/src/templates/shadcn.ts +3 -1
  81. package/src/templates/shared.ts +1 -1
package/src/inspect.ts CHANGED
@@ -2,6 +2,7 @@ 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
+ import type { DatabaseSchemaDialect, DatabaseSchemaTable } from "./choices.js";
5
6
  import {
6
7
  type BeignetConfig,
7
8
  type BeignetDatabaseTables,
@@ -15,6 +16,7 @@ import {
15
16
  type ResolvedBeignetConfig,
16
17
  resolveConfig,
17
18
  } from "./config.js";
19
+ import { renderDatabaseSchema, resolveDatabaseSchemaDialect } from "./db.js";
18
20
  import {
19
21
  applyPlannedDoctorFixes,
20
22
  createDoctorFixPlan,
@@ -390,6 +392,36 @@ async function buildDoctorFixPlan(
390
392
  );
391
393
  if (listenerFix) operations.push(listenerFix);
392
394
 
395
+ const databaseFix = await planMissingProviderTables(targetDir, files, config);
396
+ if (databaseFix) operations.push(databaseFix);
397
+
398
+ const inngestFix = await planUnregisteredInngestJobs(
399
+ targetDir,
400
+ files,
401
+ config,
402
+ );
403
+ if (inngestFix) operations.push(inngestFix);
404
+
405
+ const nonOverlappingOperations =
406
+ nonOverlappingDoctorFixOperations(operations);
407
+ operations.splice(0, operations.length, ...nonOverlappingOperations);
408
+ const sourceOverrides = new Map<string, string>();
409
+ for (const operation of operations) {
410
+ for (const change of operation.changes) {
411
+ sourceOverrides.set(change.file, change.after);
412
+ }
413
+ }
414
+ const runtimeManifestFix = await planRuntimeManifestRegistries(
415
+ targetDir,
416
+ files,
417
+ config,
418
+ convention,
419
+ sourceOverrides,
420
+ );
421
+ if (runtimeManifestFix) {
422
+ addRuntimeManifestFix(operations, runtimeManifestFix);
423
+ }
424
+
393
425
  const openApiFix = await planDirectOpenApiArrayDrift(
394
426
  targetDir,
395
427
  files,
@@ -403,10 +435,77 @@ async function buildDoctorFixPlan(
403
435
  return createDoctorFixPlan({
404
436
  targetDir,
405
437
  strict: Boolean(options.strict),
406
- operations,
438
+ operations: nonOverlappingDoctorFixOperations(operations),
407
439
  });
408
440
  }
409
441
 
442
+ function nonOverlappingDoctorFixOperations(
443
+ operations: PlannedDoctorFixOperation[],
444
+ ): PlannedDoctorFixOperation[] {
445
+ const claimedFiles = new Set<string>();
446
+ const safeOperations: PlannedDoctorFixOperation[] = [];
447
+
448
+ for (const operation of operations) {
449
+ if (operation.changes.some((change) => claimedFiles.has(change.file))) {
450
+ continue;
451
+ }
452
+ safeOperations.push(operation);
453
+ for (const change of operation.changes) claimedFiles.add(change.file);
454
+ }
455
+
456
+ return safeOperations;
457
+ }
458
+
459
+ const workflowDoctorFixOperationIds = new Set<DoctorFixOperationId>([
460
+ "schedules.register-missing",
461
+ "tasks.register-missing",
462
+ "workflows.register-missing",
463
+ "outbox.register-missing",
464
+ "listeners.register-missing",
465
+ "inngest.register-missing",
466
+ "runtime-manifest.register-missing",
467
+ ]);
468
+
469
+ function addRuntimeManifestFix(
470
+ operations: PlannedDoctorFixOperation[],
471
+ runtimeManifestFix: PlannedDoctorFixOperation,
472
+ ): void {
473
+ const runtimeChange = runtimeManifestFix.changes[0];
474
+ if (!runtimeChange) return;
475
+
476
+ const overlappingOperation = operations.find((operation) =>
477
+ operation.changes.some((change) => change.file === runtimeChange.file),
478
+ );
479
+ if (!overlappingOperation) {
480
+ operations.push(runtimeManifestFix);
481
+ return;
482
+ }
483
+ if (!workflowDoctorFixOperationIds.has(overlappingOperation.id)) return;
484
+
485
+ overlappingOperation.id = "workflows.register-missing";
486
+ overlappingOperation.fixes.push(...runtimeManifestFix.fixes);
487
+ overlappingOperation.changes = overlappingOperation.changes.map((change) =>
488
+ change.file === runtimeChange.file
489
+ ? planDoctorFixFileChange({
490
+ file: change.file,
491
+ before: change.before,
492
+ after: runtimeChange.after,
493
+ })
494
+ : change,
495
+ );
496
+
497
+ const existingWorkflowOperation = operations.find(
498
+ (operation) =>
499
+ operation !== overlappingOperation &&
500
+ operation.id === "workflows.register-missing",
501
+ );
502
+ if (!existingWorkflowOperation) return;
503
+
504
+ existingWorkflowOperation.fixes.push(...overlappingOperation.fixes);
505
+ existingWorkflowOperation.changes.push(...overlappingOperation.changes);
506
+ operations.splice(operations.indexOf(overlappingOperation), 1);
507
+ }
508
+
410
509
  /**
411
510
  * Format inspected routes as a CLI table.
412
511
  */
@@ -2347,6 +2446,10 @@ async function inspectProductionReadiness(
2347
2446
  packageJson,
2348
2447
  );
2349
2448
  const configFiles = operationalConfigFiles(files, config);
2449
+ const cronDirectory = path.posix.join(
2450
+ directoryPath(config.paths.routes),
2451
+ "cron",
2452
+ );
2350
2453
  const infrastructurePath = directoryPath(
2351
2454
  path.dirname(config.paths.portWiring),
2352
2455
  );
@@ -2380,16 +2483,17 @@ async function inspectProductionReadiness(
2380
2483
  }
2381
2484
 
2382
2485
  if (
2383
- file.startsWith("app/api/cron/") &&
2486
+ fileWithinDirectory(file, cronDirectory) &&
2384
2487
  file.endsWith("/route.ts") &&
2385
2488
  !/\bCRON_SECRET\b/.test(source) &&
2386
- !/\bcreateOutboxDrainRoute\s*\(/.test(source)
2489
+ !/\bcreateOutboxDrainRoute\s*\(/.test(source) &&
2490
+ !/\bcreateScheduleRoute\s*\(/.test(source)
2387
2491
  ) {
2388
2492
  diagnostics.push({
2389
2493
  severity: "warning",
2390
2494
  code: "BEIGNET_CRON_SECRET_MISSING",
2391
2495
  file,
2392
- message: `${file} looks like a cron route that does not reference CRON_SECRET. Protect cron entrypoints with a shared secret or a framework helper such as createOutboxDrainRoute(...).`,
2496
+ message: `${file} looks like a cron route that does not reference CRON_SECRET. Protect cron entrypoints with a shared secret or a framework helper such as createOutboxDrainRoute(...) or createScheduleRoute(...).`,
2393
2497
  });
2394
2498
  }
2395
2499
 
@@ -3264,27 +3368,28 @@ async function inspectInngestProductionPath(
3264
3368
  "utf8",
3265
3369
  );
3266
3370
  const registry = arrayInitializerInfo(registrySource, "inngestJobs");
3267
- const registeredJobs = registry
3268
- ? identifiersFromArrayExpression(registry.text)
3269
- : new Set<string>();
3270
- const featureRoot = directoryPath(config.paths.features);
3271
- const jobIndexes = files.filter((file) =>
3272
- new RegExp(`^${escapeRegExp(featureRoot)}/[^/]+/jobs/index\\.ts$`).test(
3273
- file,
3274
- ),
3371
+ const featureJobs = (
3372
+ await readFeatureWorkflowRegistries(targetDir, files, config)
3373
+ ).filter((entry) => entry.kind === "jobs");
3374
+ const registeredJobs = canonicalRegisteredWorkflowIdentifiers({
3375
+ source: registrySource,
3376
+ registered: registry
3377
+ ? identifiersFromArrayExpression(registry.text)
3378
+ : new Set<string>(),
3379
+ centralFile: registryFile,
3380
+ files,
3381
+ registries: featureJobs,
3382
+ });
3383
+ const unregisteredJobs = unregisteredWorkflowRegistries(
3384
+ featureJobs,
3385
+ registeredJobs,
3275
3386
  );
3276
- for (const jobIndex of jobIndexes) {
3277
- const source = await readFile(path.join(targetDir, jobIndex), "utf8");
3278
- const match = source.match(/export const\s+([A-Za-z_$][\w$]*Jobs)\s*=/);
3279
- const registryName = match?.[1];
3280
- if (!registryName || registeredJobs.has(registryName)) {
3281
- continue;
3282
- }
3387
+ for (const { registry: featureRegistry } of unregisteredJobs) {
3283
3388
  diagnostics.push({
3284
3389
  severity: "warning",
3285
3390
  code: "BEIGNET_INNGEST_JOB_REGISTRATION_MISSING",
3286
3391
  file: registryFile,
3287
- message: `${jobIndex} exports ${registryName}, but it is not registered in inngestJobs in ${registryFile}. Add ...${registryName} so Inngest can execute the job.`,
3392
+ message: `${featureRegistry.indexFile} exports ${featureRegistry.registryName}, but it is not registered in inngestJobs in ${registryFile}. Add ...${featureRegistry.registryName} so Inngest can execute the job.`,
3288
3393
  });
3289
3394
  }
3290
3395
  }
@@ -3882,9 +3987,15 @@ function isInfraOrServerFile(
3882
3987
  }
3883
3988
 
3884
3989
  function containsCallExpression(source: string, name: string): boolean {
3885
- return new RegExp(
3990
+ const codeIndexes = new Set(codeCharacterIndexes(source));
3991
+ const pattern = new RegExp(
3886
3992
  `\\b${escapeRegExp(name)}\\s*(?:<[^;=(){}]*>\\s*)?\\(`,
3887
- ).test(source);
3993
+ "g",
3994
+ );
3995
+
3996
+ return [...source.matchAll(pattern)].some(
3997
+ (match) => match.index !== undefined && codeIndexes.has(match.index),
3998
+ );
3888
3999
  }
3889
4000
 
3890
4001
  type WorkflowRegistryKind =
@@ -4053,6 +4164,10 @@ async function inspectRuntimeManifestDrift(
4053
4164
  files,
4054
4165
  config,
4055
4166
  );
4167
+ const manifestSource = await readFile(
4168
+ path.join(targetDir, manifest.file),
4169
+ "utf8",
4170
+ );
4056
4171
  const diagnostics: InspectDiagnostic[] = [];
4057
4172
 
4058
4173
  const checks: readonly {
@@ -4094,9 +4209,18 @@ async function inspectRuntimeManifestDrift(
4094
4209
  ];
4095
4210
 
4096
4211
  for (const check of checks) {
4212
+ const kindRegistries = registries.filter(
4213
+ (registry) => registry.kind === check.kind,
4214
+ );
4097
4215
  const missing = unregisteredWorkflowRegistries(
4098
- registries.filter((registry) => registry.kind === check.kind),
4099
- check.registered,
4216
+ kindRegistries,
4217
+ canonicalRegisteredWorkflowIdentifiers({
4218
+ source: manifestSource,
4219
+ registered: check.registered,
4220
+ centralFile: manifest.file,
4221
+ files,
4222
+ registries: kindRegistries,
4223
+ }),
4100
4224
  );
4101
4225
 
4102
4226
  for (const { registry, missingMembers } of missing) {
@@ -4271,6 +4395,48 @@ function unregisteredWorkflowRegistries(
4271
4395
  return unregistered;
4272
4396
  }
4273
4397
 
4398
+ function canonicalRegisteredWorkflowIdentifiers(options: {
4399
+ source: string;
4400
+ registered: Set<string>;
4401
+ centralFile: string;
4402
+ files: string[];
4403
+ registries: FeatureWorkflowRegistry[];
4404
+ }): Set<string> {
4405
+ const canonical = new Set(options.registered);
4406
+ const imports = parseNamedImportSources(options.source);
4407
+
4408
+ for (const identifier of options.registered) {
4409
+ const imported = imports.get(identifier);
4410
+ if (!imported) continue;
4411
+
4412
+ const importedFile = sourceFileFromImport(
4413
+ imported.sourcePath,
4414
+ options.centralFile,
4415
+ options.files,
4416
+ );
4417
+ if (!importedFile) continue;
4418
+
4419
+ for (const registry of options.registries) {
4420
+ if (
4421
+ imported.importedName === registry.registryName &&
4422
+ importedFile === registry.indexFile
4423
+ ) {
4424
+ canonical.add(registry.registryName);
4425
+ }
4426
+
4427
+ if (
4428
+ registry.members.includes(imported.importedName) &&
4429
+ (importedFile === registry.indexFile ||
4430
+ registry.memberFiles.get(imported.importedName) === importedFile)
4431
+ ) {
4432
+ canonical.add(imported.importedName);
4433
+ }
4434
+ }
4435
+ }
4436
+
4437
+ return canonical;
4438
+ }
4439
+
4274
4440
  async function readFeatureWorkflowRegistries(
4275
4441
  targetDir: string,
4276
4442
  files: string[],
@@ -4350,6 +4516,8 @@ async function readRuntimeManifestIdentifiers(
4350
4516
  targetDir: string,
4351
4517
  files: string[],
4352
4518
  ): Promise<RuntimeManifestIdentifierSets | undefined> {
4519
+ const manifests: RuntimeManifestIdentifierSets[] = [];
4520
+
4353
4521
  for (const file of files) {
4354
4522
  if (!file.endsWith(".ts") && !file.endsWith(".tsx")) continue;
4355
4523
  if (file.endsWith(".d.ts") || isWorkflowTestFile(file)) continue;
@@ -4357,25 +4525,29 @@ async function readRuntimeManifestIdentifiers(
4357
4525
  const source = await readFile(path.join(targetDir, file), "utf8");
4358
4526
  if (!source.includes("defineRuntimeManifest")) continue;
4359
4527
 
4528
+ const calls = runtimeManifestCallMatches(source);
4529
+ if (calls.length === 0) continue;
4530
+ if (calls.length !== 1) return undefined;
4360
4531
  const identifiers = runtimeManifestIdentifiers(source);
4361
4532
  if (identifiers) {
4362
- return {
4533
+ manifests.push({
4363
4534
  file,
4364
4535
  ...identifiers,
4365
- };
4536
+ });
4366
4537
  }
4367
4538
  }
4368
4539
 
4369
- return undefined;
4540
+ return manifests.length === 1 ? manifests[0] : undefined;
4370
4541
  }
4371
4542
 
4372
4543
  function runtimeManifestIdentifiers(
4373
4544
  source: string,
4374
4545
  ): Omit<RuntimeManifestIdentifierSets, "file"> | undefined {
4375
- const call = /\bdefineRuntimeManifest\s*(?:<[^>]*>\s*)?\(/.exec(source);
4376
- if (!call) return undefined;
4546
+ const calls = runtimeManifestCallMatches(source);
4547
+ if (calls.length !== 1) return undefined;
4548
+ const call = calls[0];
4377
4549
 
4378
- const arg = firstCallArgInfo(source, call.index + call[0].length);
4550
+ const arg = firstCallArgInfo(source, (call.index ?? 0) + call[0].length);
4379
4551
  if (!arg) return undefined;
4380
4552
 
4381
4553
  const outbox = objectPropertyValueInfo(arg.text, "outbox");
@@ -4393,6 +4565,15 @@ function runtimeManifestIdentifiers(
4393
4565
  };
4394
4566
  }
4395
4567
 
4568
+ function runtimeManifestCallMatches(source: string): RegExpMatchArray[] {
4569
+ const codeIndexes = new Set(codeCharacterIndexes(source));
4570
+ return [
4571
+ ...source.matchAll(/\bdefineRuntimeManifest\s*(?:<[^>]*>\s*)?\(/g),
4572
+ ].filter(
4573
+ (match) => match.index !== undefined && codeIndexes.has(match.index),
4574
+ );
4575
+ }
4576
+
4396
4577
  function identifiersFromPropertyArray(
4397
4578
  source: string,
4398
4579
  property: string,
@@ -4406,24 +4587,48 @@ function objectPropertyValueInfo(
4406
4587
  source: string,
4407
4588
  property: string,
4408
4589
  ): { text: string; start: number; end: number } | undefined {
4409
- const propertyMatch = new RegExp(`\\b${property}\\s*:`).exec(source);
4410
- if (!propertyMatch) return undefined;
4590
+ let depth = 0;
4591
+
4592
+ for (const index of codeCharacterIndexes(source)) {
4593
+ const char = source[index];
4594
+ if (char === "{" || char === "[" || char === "(") {
4595
+ depth++;
4596
+ continue;
4597
+ }
4598
+ if (char === "}" || char === "]" || char === ")") {
4599
+ depth--;
4600
+ continue;
4601
+ }
4602
+ if (
4603
+ depth !== 1 ||
4604
+ source.slice(index, index + property.length) !== property ||
4605
+ /[A-Za-z0-9_$]/.test(source[index - 1] ?? "")
4606
+ ) {
4607
+ continue;
4608
+ }
4411
4609
 
4412
- let start = propertyMatch.index + propertyMatch[0].length;
4413
- while (start < source.length && /\s/.test(source[start])) start++;
4610
+ let colon = index + property.length;
4611
+ if (/[A-Za-z0-9_$]/.test(source[colon] ?? "")) continue;
4612
+ while (colon < source.length && /\s/.test(source[colon])) colon++;
4613
+ if (source[colon] !== ":") continue;
4414
4614
 
4415
- const open = source[start];
4416
- const close = open === "[" ? "]" : open === "{" ? "}" : undefined;
4417
- if (!close) return undefined;
4615
+ let start = colon + 1;
4616
+ while (start < source.length && /\s/.test(source[start])) start++;
4617
+ const open = source[start];
4618
+ const close = open === "[" ? "]" : open === "{" ? "}" : undefined;
4619
+ if (!close) return undefined;
4418
4620
 
4419
- const end = matchingDelimiterIndex(source, start, open, close);
4420
- if (end === -1) return undefined;
4621
+ const end = matchingDelimiterIndex(source, start, open, close);
4622
+ if (end === -1) return undefined;
4421
4623
 
4422
- return {
4423
- text: source.slice(start, end + 1),
4424
- start,
4425
- end: end + 1,
4426
- };
4624
+ return {
4625
+ text: source.slice(start, end + 1),
4626
+ start,
4627
+ end: end + 1,
4628
+ };
4629
+ }
4630
+
4631
+ return undefined;
4427
4632
  }
4428
4633
 
4429
4634
  function registeredWorkflowIdentifiers(
@@ -4607,6 +4812,8 @@ async function inspectServerlessFootguns(
4607
4812
  convention: InspectConvention,
4608
4813
  ): Promise<InspectDiagnostic[]> {
4609
4814
  const diagnostics: InspectDiagnostic[] = [];
4815
+ const routeDirectory = directoryPath(config.paths.routes);
4816
+ const cronDirectory = path.posix.join(routeDirectory, "cron");
4610
4817
 
4611
4818
  for (const file of files) {
4612
4819
  if (
@@ -4617,12 +4824,9 @@ async function inspectServerlessFootguns(
4617
4824
  continue;
4618
4825
  }
4619
4826
 
4620
- const isInfraOrServerFile =
4621
- file.startsWith("infra/") || file.startsWith("server/");
4622
- const isProviderFile =
4623
- /(^|\/)provider(s)?\.ts$/.test(file) ||
4624
- /(^|\/)[^/]*provider[^/]*\.ts$/.test(file);
4625
4827
  const source = await readFile(path.join(targetDir, file), "utf8");
4828
+ const isInfraOrServerFile = isRuntimeFile(file, config);
4829
+ const isProviderFile = isProviderLifecycleFile(file, source);
4626
4830
 
4627
4831
  if (
4628
4832
  config.framework === "next" &&
@@ -4666,7 +4870,7 @@ async function inspectServerlessFootguns(
4666
4870
  }
4667
4871
 
4668
4872
  if (
4669
- file.startsWith("app/api/cron/") &&
4873
+ fileWithinDirectory(file, cronDirectory) &&
4670
4874
  file.endsWith("/route.ts") &&
4671
4875
  !/\bauthorization\b/i.test(source) &&
4672
4876
  !/\bcreateOutboxDrainRoute\s*\(/.test(source) &&
@@ -4682,8 +4886,8 @@ async function inspectServerlessFootguns(
4682
4886
  }
4683
4887
 
4684
4888
  if (
4685
- hasOutboxRegistry(files) &&
4686
- !(await appHasOutboxDrainEntrypoint(targetDir, files))
4889
+ hasOutboxRegistry(files, config) &&
4890
+ !(await appHasOutboxDrainEntrypoint(targetDir, files, config))
4687
4891
  ) {
4688
4892
  diagnostics.push({
4689
4893
  severity: "warning",
@@ -4696,6 +4900,82 @@ async function inspectServerlessFootguns(
4696
4900
  return diagnostics;
4697
4901
  }
4698
4902
 
4903
+ function configuredRuntimeFiles(config: ResolvedBeignetConfig): string[] {
4904
+ return [
4905
+ config.paths.server,
4906
+ config.paths.listeners,
4907
+ config.paths.tasks,
4908
+ config.paths.outbox,
4909
+ config.paths.schedules,
4910
+ config.paths.portWiring,
4911
+ ];
4912
+ }
4913
+
4914
+ function configuredRuntimeDirectories(config: ResolvedBeignetConfig): string[] {
4915
+ const applicationDirectories = configuredApplicationDirectories(config);
4916
+
4917
+ return configuredRuntimeFiles(config)
4918
+ .map((file) => directoryPath(path.posix.dirname(file)))
4919
+ .filter((directory) => directory !== ".")
4920
+ .filter(
4921
+ (directory) =>
4922
+ !applicationDirectories.some(
4923
+ (applicationDirectory) =>
4924
+ applicationDirectory !== directory &&
4925
+ fileWithinDirectory(applicationDirectory, directory),
4926
+ ),
4927
+ )
4928
+ .filter((directory, index, all) => all.indexOf(directory) === index);
4929
+ }
4930
+
4931
+ function configuredApplicationDirectories(
4932
+ config: ResolvedBeignetConfig,
4933
+ ): string[] {
4934
+ return [
4935
+ config.paths.contracts,
4936
+ config.paths.routes,
4937
+ config.paths.features,
4938
+ config.paths.policies,
4939
+ config.paths.useCases,
4940
+ config.paths.tests,
4941
+ ]
4942
+ .map(directoryPath)
4943
+ .filter((directory) => directory !== ".")
4944
+ .filter((directory, index, all) => all.indexOf(directory) === index);
4945
+ }
4946
+
4947
+ function isRuntimeFile(file: string, config: ResolvedBeignetConfig): boolean {
4948
+ if (configuredRuntimeFiles(config).includes(file)) return true;
4949
+ if (
4950
+ configuredApplicationDirectories(config).some((directory) =>
4951
+ fileWithinDirectory(file, directory),
4952
+ )
4953
+ ) {
4954
+ return false;
4955
+ }
4956
+
4957
+ return (
4958
+ fileWithinDirectory(file, "infra") ||
4959
+ fileWithinDirectory(file, "server") ||
4960
+ configuredRuntimeDirectories(config).some((directory) =>
4961
+ fileWithinDirectory(file, directory),
4962
+ )
4963
+ );
4964
+ }
4965
+
4966
+ function fileWithinDirectory(file: string, directory: string): boolean {
4967
+ return directory === "." || file.startsWith(`${directory}/`);
4968
+ }
4969
+
4970
+ function isProviderLifecycleFile(file: string, source?: string): boolean {
4971
+ return (
4972
+ /(^|\/)provider(s)?\.ts$/.test(file) ||
4973
+ /(^|\/)[^/]*provider[^/]*\.ts$/.test(file) ||
4974
+ /(^|\/)providers?\//.test(file) ||
4975
+ (source !== undefined && containsCallExpression(source, "createProvider"))
4976
+ );
4977
+ }
4978
+
4699
4979
  function isNextRouteFile(file: string, config: ResolvedBeignetConfig): boolean {
4700
4980
  const routesPath = directoryPath(config.paths.routes);
4701
4981
 
@@ -4727,33 +5007,35 @@ function importsEagerServer(
4727
5007
  return false;
4728
5008
  }
4729
5009
 
4730
- function hasOutboxRegistry(files: string[]): boolean {
4731
- return files.some(isOutboxRegistryFile);
4732
- }
4733
-
4734
- function isOutboxRegistryFile(file: string): boolean {
4735
- return file === "server/outbox.ts";
5010
+ function hasOutboxRegistry(
5011
+ files: string[],
5012
+ config: ResolvedBeignetConfig,
5013
+ ): boolean {
5014
+ return files.includes(config.paths.outbox);
4736
5015
  }
4737
5016
 
4738
5017
  async function appHasOutboxDrainEntrypoint(
4739
5018
  targetDir: string,
4740
5019
  files: string[],
5020
+ config: ResolvedBeignetConfig,
4741
5021
  ): Promise<boolean> {
5022
+ const routesDirectory = directoryPath(config.paths.routes);
5023
+
4742
5024
  for (const file of files) {
4743
5025
  if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
4744
5026
  if (
4745
- !file.startsWith("app/api/") &&
4746
- !file.startsWith("server/") &&
4747
- !file.startsWith("infra/")
5027
+ !fileWithinDirectory(file, routesDirectory) &&
5028
+ !isRuntimeFile(file, config)
4748
5029
  ) {
4749
5030
  continue;
4750
5031
  }
4751
5032
 
4752
5033
  const source = await readFile(path.join(targetDir, file), "utf8");
4753
- if (
4754
- /\bcreateOutboxDrainRoute\s*\(/.test(source) ||
4755
- /\bdrainOutbox\s*\(/.test(source)
4756
- ) {
5034
+ if (containsCallExpression(source, "createOutboxDrainRoute")) {
5035
+ return true;
5036
+ }
5037
+ if (isProviderLifecycleFile(file, source)) continue;
5038
+ if (containsCallExpression(source, "drainOutbox")) {
4757
5039
  return true;
4758
5040
  }
4759
5041
  }
@@ -4948,7 +5230,9 @@ async function inspectDatabaseLifecycleDrift(
4948
5230
  );
4949
5231
  for (const schemaFile of schemaFiles) {
4950
5232
  const moduleName = path.basename(schemaFile, ".ts");
4951
- if (schemaIndexSource.includes(`"./${moduleName}"`)) continue;
5233
+ if (schemaIndexExportKind(schemaIndexSource, `./${moduleName}`)) {
5234
+ continue;
5235
+ }
4952
5236
  diagnostics.push({
4953
5237
  severity: "warning",
4954
5238
  code: "BEIGNET_DB_SCHEMA_EXPORT_MISSING",
@@ -6815,6 +7099,467 @@ function findRouteGroupContract(
6815
7099
  );
6816
7100
  }
6817
7101
 
7102
+ const managedDatabaseSchemaMarker = "// Managed by beignet db schema sync.";
7103
+ const databaseSchemaExports: Record<DatabaseSchemaTable, string> = {
7104
+ audit: "auditLog",
7105
+ idempotency: "idempotencyRecords",
7106
+ outbox: "outboxMessages",
7107
+ };
7108
+
7109
+ async function planMissingProviderTables(
7110
+ targetDir: string,
7111
+ files: string[],
7112
+ config: ResolvedBeignetConfig,
7113
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7114
+ const infrastructurePath = directoryPath(
7115
+ path.dirname(config.paths.portWiring),
7116
+ );
7117
+ const schemaDir = `${infrastructurePath}/db/schema`;
7118
+ const output = `${schemaDir}/beignet.ts`;
7119
+ const indexFile = `${schemaDir}/index.ts`;
7120
+ if (!files.includes(indexFile)) return undefined;
7121
+
7122
+ const sourceCache = new Map<string, string>();
7123
+ const usageFiles = durableDrizzleUsageFiles(files, config);
7124
+ const requiredDefaultTables: DatabaseSchemaTable[] = [];
7125
+ const missingTables: DatabaseSchemaTable[] = [];
7126
+ const fixes: InspectFix[] = [];
7127
+
7128
+ for (const requirement of durableDrizzleTableRequirements) {
7129
+ const defaultTableName =
7130
+ defaultBeignetConfig.database.tables[requirement.table];
7131
+ if (config.database.tables[requirement.table] !== defaultTableName) {
7132
+ continue;
7133
+ }
7134
+
7135
+ const configuredTables = await configuredDurableDrizzleTableNames(
7136
+ targetDir,
7137
+ usageFiles,
7138
+ config,
7139
+ requirement,
7140
+ sourceCache,
7141
+ );
7142
+ if (!configuredTables.has(defaultTableName)) continue;
7143
+ requiredDefaultTables.push(requirement.table);
7144
+
7145
+ if (
7146
+ await durableDrizzleTableExists(
7147
+ targetDir,
7148
+ files,
7149
+ config,
7150
+ infrastructurePath,
7151
+ schemaDir,
7152
+ requirement,
7153
+ defaultTableName,
7154
+ sourceCache,
7155
+ )
7156
+ ) {
7157
+ continue;
7158
+ }
7159
+
7160
+ missingTables.push(requirement.table);
7161
+ fixes.push({
7162
+ code: requirement.code,
7163
+ file: output,
7164
+ message: `Added the default ${defaultTableName} provider table export to ${output}.`,
7165
+ });
7166
+ }
7167
+
7168
+ const outputPath = path.join(targetDir, output);
7169
+ let originalOutput: string | undefined;
7170
+ try {
7171
+ originalOutput = await readFile(outputPath, "utf8");
7172
+ } catch (error) {
7173
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
7174
+ }
7175
+ const existingTables =
7176
+ originalOutput === undefined
7177
+ ? []
7178
+ : managedDatabaseSchemaTables(originalOutput);
7179
+ if (existingTables === undefined) return undefined;
7180
+ const originalIndex = await readFile(path.join(targetDir, indexFile), "utf8");
7181
+ const managedExportKind = schemaIndexExportKind(originalIndex, "./beignet");
7182
+ if (managedExportKind === "named") return undefined;
7183
+ const needsManagedExport =
7184
+ originalOutput !== undefined &&
7185
+ existingTables.some((table) => requiredDefaultTables.includes(table)) &&
7186
+ managedExportKind === undefined;
7187
+ if (missingTables.length === 0 && !needsManagedExport) return undefined;
7188
+ if (needsManagedExport) {
7189
+ fixes.push({
7190
+ code: "BEIGNET_DB_SCHEMA_EXPORT_MISSING",
7191
+ file: indexFile,
7192
+ message: `Exported ${output} from ${indexFile}.`,
7193
+ });
7194
+ }
7195
+
7196
+ const selectedTables = (
7197
+ Object.keys(databaseSchemaExports) as DatabaseSchemaTable[]
7198
+ ).filter(
7199
+ (table) => existingTables.includes(table) || missingTables.includes(table),
7200
+ );
7201
+
7202
+ let nextOutput = originalOutput;
7203
+ if (missingTables.length > 0) {
7204
+ let dialect: Awaited<ReturnType<typeof resolveDatabaseSchemaDialect>>;
7205
+ try {
7206
+ dialect = await resolveDatabaseSchemaDialect(targetDir, undefined);
7207
+ } catch {
7208
+ return undefined;
7209
+ }
7210
+ nextOutput = renderDatabaseSchema(dialect, selectedTables);
7211
+ }
7212
+ if (nextOutput === undefined) return undefined;
7213
+
7214
+ const addedExportNames = missingTables.map(
7215
+ (table) => databaseSchemaExports[table],
7216
+ );
7217
+ const otherSchemaFiles = files.filter(
7218
+ (file) =>
7219
+ file.startsWith(`${schemaDir}/`) &&
7220
+ file.endsWith(".ts") &&
7221
+ file !== indexFile &&
7222
+ file !== output,
7223
+ );
7224
+ for (const schemaFile of [indexFile, ...otherSchemaFiles]) {
7225
+ const source =
7226
+ schemaFile === indexFile
7227
+ ? originalIndex
7228
+ : await readFile(path.join(targetDir, schemaFile), "utf8");
7229
+ if (addedExportNames.some((name) => exportsIdentifier(source, name))) {
7230
+ return undefined;
7231
+ }
7232
+ }
7233
+ const nextIndex =
7234
+ managedExportKind === "all"
7235
+ ? originalIndex
7236
+ : `${originalIndex.trimEnd()}\nexport * from "./beignet";\n`;
7237
+
7238
+ const changes = [
7239
+ ...(originalOutput === nextOutput
7240
+ ? []
7241
+ : [
7242
+ planDoctorFixFileChange({
7243
+ file: output,
7244
+ before: originalOutput,
7245
+ after: nextOutput,
7246
+ }),
7247
+ ]),
7248
+ ...(originalIndex === nextIndex
7249
+ ? []
7250
+ : [
7251
+ planDoctorFixFileChange({
7252
+ file: indexFile,
7253
+ before: originalIndex,
7254
+ after: nextIndex,
7255
+ }),
7256
+ ]),
7257
+ ];
7258
+ if (changes.length === 0) return undefined;
7259
+
7260
+ return {
7261
+ id: "database.sync-provider-tables",
7262
+ fixes,
7263
+ changes,
7264
+ };
7265
+ }
7266
+
7267
+ function schemaIndexExportKind(
7268
+ source: string,
7269
+ moduleSpecifier: string,
7270
+ ): "all" | "named" | undefined {
7271
+ const codeIndexes = new Set(codeCharacterIndexes(source));
7272
+ const escaped = escapeRegExp(moduleSpecifier);
7273
+ const allPattern = new RegExp(
7274
+ `\\bexport\\s*\\*\\s*from\\s*["']${escaped}["']`,
7275
+ "g",
7276
+ );
7277
+ if (
7278
+ [...source.matchAll(allPattern)].some(
7279
+ (match) => match.index !== undefined && codeIndexes.has(match.index),
7280
+ )
7281
+ ) {
7282
+ return "all";
7283
+ }
7284
+
7285
+ const customPatterns = [
7286
+ new RegExp(
7287
+ `\\bexport\\s*\\{[\\s\\S]*?\\}\\s*from\\s*["']${escaped}["']`,
7288
+ "g",
7289
+ ),
7290
+ new RegExp(
7291
+ `\\bexport\\s*\\*\\s+as\\s+[A-Za-z_$][\\w$]*\\s+from\\s*["']${escaped}["']`,
7292
+ "g",
7293
+ ),
7294
+ ];
7295
+ return customPatterns.some((pattern) =>
7296
+ [...source.matchAll(pattern)].some(
7297
+ (match) => match.index !== undefined && codeIndexes.has(match.index),
7298
+ ),
7299
+ )
7300
+ ? "named"
7301
+ : undefined;
7302
+ }
7303
+
7304
+ function exportsIdentifier(source: string, identifier: string): boolean {
7305
+ const executableSource = Array.from({ length: source.length }, () => " ");
7306
+ for (const index of codeCharacterIndexes(source)) {
7307
+ executableSource[index] = source.charAt(index);
7308
+ }
7309
+
7310
+ const escaped = escapeRegExp(identifier);
7311
+ const patterns = [
7312
+ new RegExp(
7313
+ `\\bexport\\s+(?:const|let|var|class|function)\\s+${escaped}\\b`,
7314
+ ),
7315
+ new RegExp(`\\bexport\\s*\\{[^}]*\\b${escaped}\\b[^}]*\\}`),
7316
+ ];
7317
+
7318
+ const code = executableSource.join("");
7319
+ return patterns.some((pattern) => pattern.test(code));
7320
+ }
7321
+
7322
+ function managedDatabaseSchemaTables(
7323
+ source: string,
7324
+ ): DatabaseSchemaTable[] | undefined {
7325
+ if (!source.startsWith(managedDatabaseSchemaMarker)) return undefined;
7326
+
7327
+ const dialect =
7328
+ /@beignet\/provider-db-drizzle\/(sqlite|postgres|mysql)\/schema/.exec(
7329
+ source,
7330
+ )?.[1] as DatabaseSchemaDialect | undefined;
7331
+ if (!dialect) return undefined;
7332
+
7333
+ const tables = (
7334
+ Object.entries(databaseSchemaExports) as Array<
7335
+ [DatabaseSchemaTable, string]
7336
+ >
7337
+ )
7338
+ .filter(([, exportName]) =>
7339
+ new RegExp(`\\b${escapeRegExp(exportName)}\\b`).test(source),
7340
+ )
7341
+ .map(([table]) => table);
7342
+ if (tables.length === 0) return undefined;
7343
+
7344
+ return renderDatabaseSchema(dialect, tables) === source ? tables : undefined;
7345
+ }
7346
+
7347
+ async function planUnregisteredInngestJobs(
7348
+ targetDir: string,
7349
+ files: string[],
7350
+ config: ResolvedBeignetConfig,
7351
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7352
+ const packageJson = await readPackageJson(targetDir, files);
7353
+ if (
7354
+ !installedPackageNames(packageJson).has("@beignet/provider-jobs-inngest")
7355
+ ) {
7356
+ return undefined;
7357
+ }
7358
+
7359
+ const serverDir = directoryPath(path.dirname(config.paths.server));
7360
+ const registryFile = `${serverDir}/inngest.ts`;
7361
+ if (!files.includes(registryFile)) return undefined;
7362
+
7363
+ const source = await readFile(path.join(targetDir, registryFile), "utf8");
7364
+ const registry = arrayInitializerInfo(source, "inngestJobs");
7365
+ if (!registry) return undefined;
7366
+
7367
+ const featureJobs = (
7368
+ await readFeatureWorkflowRegistries(targetDir, files, config)
7369
+ ).filter((entry) => entry.kind === "jobs");
7370
+ const unregistered = unregisteredWorkflowRegistries(
7371
+ featureJobs,
7372
+ canonicalRegisteredWorkflowIdentifiers({
7373
+ source,
7374
+ registered: identifiersFromArrayExpression(registry.text),
7375
+ centralFile: registryFile,
7376
+ files,
7377
+ registries: featureJobs,
7378
+ }),
7379
+ );
7380
+
7381
+ return planWorkflowRegistryOperation("inngest.register-missing", {
7382
+ targetDir,
7383
+ files,
7384
+ centralFile: registryFile,
7385
+ unregistered,
7386
+ code: "BEIGNET_INNGEST_JOB_REGISTRATION_MISSING",
7387
+ listName: "the Inngest jobs array",
7388
+ importSpecifier: (indexFile) => relativeModule(registryFile, indexFile),
7389
+ append: (nextSource, entry, importLine) =>
7390
+ appendToNamedArray(nextSource, "inngestJobs", entry, importLine),
7391
+ });
7392
+ }
7393
+
7394
+ const runtimeManifestRepairChecks: readonly {
7395
+ kind: WorkflowRegistryKind;
7396
+ code: string;
7397
+ listName: string;
7398
+ }[] = [
7399
+ {
7400
+ kind: "listeners",
7401
+ code: "BEIGNET_RUNTIME_MANIFEST_LISTENER_MISSING",
7402
+ listName: "the runtime manifest listeners array",
7403
+ },
7404
+ {
7405
+ kind: "schedules",
7406
+ code: "BEIGNET_RUNTIME_MANIFEST_SCHEDULE_MISSING",
7407
+ listName: "the runtime manifest schedules array",
7408
+ },
7409
+ {
7410
+ kind: "tasks",
7411
+ code: "BEIGNET_RUNTIME_MANIFEST_TASK_MISSING",
7412
+ listName: "the runtime manifest tasks array",
7413
+ },
7414
+ {
7415
+ kind: "events",
7416
+ code: "BEIGNET_RUNTIME_MANIFEST_OUTBOX_EVENT_MISSING",
7417
+ listName: "the runtime manifest outbox events array",
7418
+ },
7419
+ {
7420
+ kind: "jobs",
7421
+ code: "BEIGNET_RUNTIME_MANIFEST_OUTBOX_JOB_MISSING",
7422
+ listName: "the runtime manifest outbox jobs array",
7423
+ },
7424
+ ];
7425
+
7426
+ async function planRuntimeManifestRegistries(
7427
+ targetDir: string,
7428
+ files: string[],
7429
+ config: ResolvedBeignetConfig,
7430
+ convention: InspectConvention,
7431
+ sourceOverrides: ReadonlyMap<string, string> = new Map(),
7432
+ ): Promise<PlannedDoctorFixOperation | undefined> {
7433
+ if (!convention.resourceGenerator) return undefined;
7434
+
7435
+ const manifest = await readRuntimeManifestIdentifiers(targetDir, files);
7436
+ if (!manifest) return undefined;
7437
+
7438
+ const original =
7439
+ sourceOverrides.get(manifest.file) ??
7440
+ (await readFile(path.join(targetDir, manifest.file), "utf8"));
7441
+ const registries = await readFeatureWorkflowRegistries(
7442
+ targetDir,
7443
+ files,
7444
+ config,
7445
+ );
7446
+ let next = original;
7447
+ const fixes: InspectFix[] = [];
7448
+
7449
+ for (const check of runtimeManifestRepairChecks) {
7450
+ const parsedRegistered =
7451
+ runtimeManifestIdentifiers(next)?.[
7452
+ runtimeManifestIdentifierKey(check.kind)
7453
+ ];
7454
+ if (!parsedRegistered) continue;
7455
+ const kindRegistries = registries.filter(
7456
+ (registry) => registry.kind === check.kind,
7457
+ );
7458
+ const registered = canonicalRegisteredWorkflowIdentifiers({
7459
+ source: next,
7460
+ registered: parsedRegistered,
7461
+ centralFile: manifest.file,
7462
+ files,
7463
+ registries: kindRegistries,
7464
+ });
7465
+
7466
+ const edit = planWorkflowRegistryEdit({
7467
+ files,
7468
+ centralFile: manifest.file,
7469
+ source: next,
7470
+ unregistered: unregisteredWorkflowRegistries(kindRegistries, registered),
7471
+ code: check.code,
7472
+ listName: check.listName,
7473
+ importSpecifier: (indexFile) => relativeModule(manifest.file, indexFile),
7474
+ append: (nextSource, entry, importLine) =>
7475
+ appendToRuntimeManifestArray(nextSource, check.kind, entry, importLine),
7476
+ });
7477
+ if (!edit) continue;
7478
+ next = edit.after;
7479
+ fixes.push(edit.fix);
7480
+ }
7481
+
7482
+ if (fixes.length === 0 || next === original) return undefined;
7483
+
7484
+ return {
7485
+ id: "runtime-manifest.register-missing",
7486
+ fixes,
7487
+ changes: [
7488
+ planDoctorFixFileChange({
7489
+ file: manifest.file,
7490
+ before: original,
7491
+ after: next,
7492
+ }),
7493
+ ],
7494
+ };
7495
+ }
7496
+
7497
+ function runtimeManifestIdentifierKey(
7498
+ kind: WorkflowRegistryKind,
7499
+ ): keyof Omit<RuntimeManifestIdentifierSets, "file"> {
7500
+ return kind;
7501
+ }
7502
+
7503
+ function runtimeManifestArrayInfo(
7504
+ source: string,
7505
+ kind: WorkflowRegistryKind,
7506
+ ): { text: string; start: number; end: number } | undefined {
7507
+ const calls = runtimeManifestCallMatches(source);
7508
+ if (calls.length !== 1) return undefined;
7509
+ const call = calls[0];
7510
+
7511
+ const arg = firstCallArgInfo(source, (call.index ?? 0) + call[0].length);
7512
+ if (!arg) return undefined;
7513
+
7514
+ if (kind === "listeners" || kind === "schedules" || kind === "tasks") {
7515
+ const value = objectPropertyValueInfo(arg.text, kind);
7516
+ return value
7517
+ ? {
7518
+ text: value.text,
7519
+ start: arg.start + value.start,
7520
+ end: arg.start + value.end,
7521
+ }
7522
+ : undefined;
7523
+ }
7524
+
7525
+ const outbox = objectPropertyValueInfo(arg.text, "outbox");
7526
+ if (!outbox) return undefined;
7527
+ const value = objectPropertyValueInfo(outbox.text, kind);
7528
+ return value
7529
+ ? {
7530
+ text: value.text,
7531
+ start: arg.start + outbox.start + value.start,
7532
+ end: arg.start + outbox.start + value.end,
7533
+ }
7534
+ : undefined;
7535
+ }
7536
+
7537
+ function appendToRuntimeManifestArray(
7538
+ source: string,
7539
+ kind: WorkflowRegistryKind,
7540
+ entry: string,
7541
+ importLine?: string,
7542
+ ): AppendResult {
7543
+ const value = runtimeManifestArrayInfo(source, kind);
7544
+ if (!value?.text.startsWith("[")) return { kind: "missing" };
7545
+
7546
+ const entryName = entry.replace(/^\.{3}/, "").trim();
7547
+ if (identifiersFromArrayExpression(value.text).has(entryName)) {
7548
+ return { kind: "unchanged" };
7549
+ }
7550
+
7551
+ const nextArray = appendToArrayExpression(value.text, [entry]);
7552
+ let next = `${source.slice(0, value.start)}${nextArray}${source.slice(
7553
+ value.end,
7554
+ )}`;
7555
+ if (importLine && !next.includes(importLine)) {
7556
+ next = insertAfterImports(next, importLine);
7557
+ }
7558
+ return next === source
7559
+ ? { kind: "unchanged" }
7560
+ : { kind: "updated", source: next };
7561
+ }
7562
+
6818
7563
  async function planDirectOpenApiArrayDrift(
6819
7564
  targetDir: string,
6820
7565
  files: string[],
@@ -7372,7 +8117,8 @@ function planWorkflowRegistryEdit(
7372
8117
  const registeredNames: string[] = [];
7373
8118
 
7374
8119
  for (const { registry } of candidates) {
7375
- const imported = parseNamedImportSources(next).get(registry.registryName);
8120
+ const imports = parseNamedImportSources(next);
8121
+ const imported = imports.get(registry.registryName);
7376
8122
  let importLine: string | undefined;
7377
8123
 
7378
8124
  if (imported) {
@@ -7383,6 +8129,21 @@ function planWorkflowRegistryEdit(
7383
8129
  );
7384
8130
  if (importedFile !== registry.indexFile) return undefined;
7385
8131
  } else {
8132
+ const aliasesRegistry = [...imports.entries()].some(
8133
+ ([localName, candidate]) =>
8134
+ localName !== registry.registryName &&
8135
+ candidate.importedName === registry.registryName &&
8136
+ sourceFileFromImport(
8137
+ candidate.sourcePath,
8138
+ options.centralFile,
8139
+ options.files,
8140
+ ) === registry.indexFile,
8141
+ );
8142
+ if (aliasesRegistry) return undefined;
8143
+ if (sourceContainsCodeIdentifier(next, registry.registryName)) {
8144
+ return undefined;
8145
+ }
8146
+
7386
8147
  importLine = `import { ${registry.registryName} } from "${options.importSpecifier(
7387
8148
  registry.indexFile,
7388
8149
  )}";`;
@@ -7412,6 +8173,22 @@ function planWorkflowRegistryEdit(
7412
8173
  };
7413
8174
  }
7414
8175
 
8176
+ function sourceContainsCodeIdentifier(
8177
+ source: string,
8178
+ identifier: string,
8179
+ ): boolean {
8180
+ const codeIndexes = new Set(codeCharacterIndexes(source));
8181
+ const pattern = new RegExp(
8182
+ `(^|[^A-Za-z0-9_$])${escapeRegExp(identifier)}(?![A-Za-z0-9_$])`,
8183
+ "g",
8184
+ );
8185
+ return [...source.matchAll(pattern)].some(
8186
+ (match) =>
8187
+ match.index !== undefined &&
8188
+ codeIndexes.has(match.index + (match[1]?.length ?? 0)),
8189
+ );
8190
+ }
8191
+
7415
8192
  function routeRegistryFileFromServerSource(
7416
8193
  source: string,
7417
8194
  serverFile: string,