@beignet/cli 0.0.32 → 0.0.34

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 (92) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +29 -19
  3. package/dist/choices.d.ts +3 -3
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +2 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/config.d.ts +1 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +1 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/db.js +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +279 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/inspect.d.ts.map +1 -1
  16. package/dist/inspect.js +800 -60
  17. package/dist/inspect.js.map +1 -1
  18. package/dist/make/inbox.d.ts.map +1 -1
  19. package/dist/make/inbox.js +8 -6
  20. package/dist/make/inbox.js.map +1 -1
  21. package/dist/make/payments.d.ts.map +1 -1
  22. package/dist/make/payments.js +61 -32
  23. package/dist/make/payments.js.map +1 -1
  24. package/dist/make/shared.d.ts +11 -2
  25. package/dist/make/shared.d.ts.map +1 -1
  26. package/dist/make/shared.js +45 -11
  27. package/dist/make/shared.js.map +1 -1
  28. package/dist/make/tenancy.d.ts.map +1 -1
  29. package/dist/make/tenancy.js +9 -7
  30. package/dist/make/tenancy.js.map +1 -1
  31. package/dist/make.d.ts.map +1 -1
  32. package/dist/make.js +134 -60
  33. package/dist/make.js.map +1 -1
  34. package/dist/outbox.d.ts +114 -1
  35. package/dist/outbox.d.ts.map +1 -1
  36. package/dist/outbox.js +190 -0
  37. package/dist/outbox.js.map +1 -1
  38. package/dist/preflight.js +1 -1
  39. package/dist/preflight.js.map +1 -1
  40. package/dist/provider-add.d.ts.map +1 -1
  41. package/dist/provider-add.js +91 -62
  42. package/dist/provider-add.js.map +1 -1
  43. package/dist/registry-edits.js +2 -1
  44. package/dist/registry-edits.js.map +1 -1
  45. package/dist/task.d.ts.map +1 -1
  46. package/dist/task.js +2 -3
  47. package/dist/task.js.map +1 -1
  48. package/dist/templates/agents.d.ts.map +1 -1
  49. package/dist/templates/agents.js +6 -3
  50. package/dist/templates/agents.js.map +1 -1
  51. package/dist/templates/base.js +6 -6
  52. package/dist/templates/base.js.map +1 -1
  53. package/dist/templates/index.d.ts.map +1 -1
  54. package/dist/templates/index.js +2 -0
  55. package/dist/templates/index.js.map +1 -1
  56. package/dist/templates/server.d.ts.map +1 -1
  57. package/dist/templates/server.js +17 -10
  58. package/dist/templates/server.js.map +1 -1
  59. package/dist/templates/shared.d.ts +3 -1
  60. package/dist/templates/shared.d.ts.map +1 -1
  61. package/dist/templates/shared.js +11 -1
  62. package/dist/templates/shared.js.map +1 -1
  63. package/dist/templates/testing.d.ts +5 -0
  64. package/dist/templates/testing.d.ts.map +1 -0
  65. package/dist/templates/testing.js +542 -0
  66. package/dist/templates/testing.js.map +1 -0
  67. package/dist/templates/todos.js +4 -4
  68. package/package.json +3 -2
  69. package/skills/app-structure/SKILL.md +14 -4
  70. package/src/choices.ts +3 -0
  71. package/src/config.ts +2 -0
  72. package/src/db.ts +1 -1
  73. package/src/index.ts +437 -1
  74. package/src/inspect.ts +1248 -156
  75. package/src/make/inbox.ts +8 -6
  76. package/src/make/payments.ts +61 -32
  77. package/src/make/shared.ts +79 -12
  78. package/src/make/tenancy.ts +9 -7
  79. package/src/make.ts +177 -59
  80. package/src/outbox.ts +363 -0
  81. package/src/preflight.ts +1 -1
  82. package/src/provider-add.ts +92 -62
  83. package/src/registry-edits.ts +2 -1
  84. package/src/task.ts +2 -3
  85. package/src/templates/agents.ts +6 -3
  86. package/src/templates/base.ts +6 -6
  87. package/src/templates/index.ts +2 -0
  88. package/src/templates/server.ts +17 -10
  89. package/src/templates/shared.ts +13 -1
  90. package/src/templates/testing.ts +545 -0
  91. package/src/templates/todos.ts +4 -4
  92. package/src/test-helpers/generated-app.ts +10 -6
package/src/inspect.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, stat, writeFile } 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";
@@ -20,7 +20,10 @@ import {
20
20
  type GithubAnnotationSeverity,
21
21
  } from "./github-annotations.js";
22
22
  import {
23
+ createListenersRegistrySource,
24
+ listenersFilePath,
23
25
  providersFilePath,
26
+ updateListenersRegistrySource,
24
27
  wireListenersProviderSource,
25
28
  } from "./make/shared.js";
26
29
  import {
@@ -49,6 +52,12 @@ import {
49
52
  matchingDelimiterIndex,
50
53
  outboxRegistryValueInfo,
51
54
  } from "./registry-edits.js";
55
+ import {
56
+ currentGeneratedPackageScripts,
57
+ externalVersions,
58
+ legacyGeneratedPackageScripts,
59
+ } from "./templates/shared.js";
60
+ import { testSupportTemplateFiles } from "./templates/testing.js";
52
61
  import { getCliVersion } from "./version.js";
53
62
 
54
63
  type InspectAppOptions = {
@@ -246,7 +255,7 @@ export async function applyDoctorFixes(
246
255
  const matchedRoutes = matchRoutes(contracts, routeExports);
247
256
  const fixes: InspectFix[] = [];
248
257
 
249
- const packageFix = await fixMissingTestScript(targetDir, files, convention);
258
+ const packageFix = await fixPackageScripts(targetDir, files, convention);
250
259
  if (packageFix) fixes.push(packageFix);
251
260
 
252
261
  const routeGroupFix = await fixUnregisteredRouteGroups(
@@ -418,6 +427,7 @@ export const productionHardeningDiagnosticCodes = new Set([
418
427
  "BEIGNET_PROVIDER_ENV_MISSING",
419
428
  "BEIGNET_PROVIDER_TABLE_MISSING",
420
429
  "BEIGNET_READINESS_ROUTE_MISSING",
430
+ "BEIGNET_SECURITY_HEADERS_MISSING",
421
431
  "BEIGNET_TENANT_HEADER_AUTHORITY",
422
432
  "BEIGNET_UPLOAD_AUTHORIZATION_MISSING",
423
433
  "BEIGNET_UPLOAD_SIZE_LIMIT_MISSING",
@@ -438,7 +448,7 @@ function productionHardeningChecklist(
438
448
  "Secrets and provider credentials are unique per environment, validated at startup, never committed, and never logged.",
439
449
  "Auth routes, tenant resolution, authorization policies, and trusted origins derive authority from verified sessions, API keys, or trusted gateway metadata.",
440
450
  "Devtools, OpenAPI, cron, webhooks, and operational routes have intentional exposure and app-owned authorization.",
441
- "CORS origins, proxy IP trust, rate-limit keys, and request body limits match the production host topology.",
451
+ "Security headers, CORS origins, proxy IP trust, rate-limit keys, and request body limits match the production host topology.",
442
452
  "Uploads and storage define max sizes, authorization or explicit public access, object visibility, safe content headers, and short direct-upload expirations.",
443
453
  "Provider-backed dependencies have bounded readiness checks, worker shutdown behavior, webhook secrets, and least-privilege credentials.",
444
454
  ];
@@ -1432,6 +1442,12 @@ async function inspectDiagnostics(
1432
1442
  config,
1433
1443
  convention,
1434
1444
  )),
1445
+ ...(await inspectRuntimeManifestDrift(
1446
+ targetDir,
1447
+ files,
1448
+ config,
1449
+ convention,
1450
+ )),
1435
1451
  ...(await inspectResourceSlices(
1436
1452
  targetDir,
1437
1453
  files,
@@ -1969,6 +1985,7 @@ const readinessRelevantProviderPackages = new Set([
1969
1985
  "@beignet/provider-auth-better-auth",
1970
1986
  "@beignet/provider-db-drizzle",
1971
1987
  "@beignet/provider-error-reporting-sentry",
1988
+ "@beignet/provider-event-bus-redis",
1972
1989
  "@beignet/provider-flags-openfeature",
1973
1990
  "@beignet/provider-jobs-bullmq",
1974
1991
  "@beignet/provider-jobs-inngest",
@@ -2003,11 +2020,20 @@ async function inspectProductionReadiness(
2003
2020
  path.dirname(config.paths.infrastructurePorts),
2004
2021
  );
2005
2022
  const schemaDir = `${infrastructurePath}/db/schema`;
2023
+ let hasSecurityHeadersHook = false;
2006
2024
 
2007
2025
  for (const file of files) {
2008
2026
  if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
2009
2027
 
2010
2028
  const source = await readCachedSource(targetDir, file, sourceCache);
2029
+ const routeServerOrInfra = isRouteServerOrInfraFile(file, config);
2030
+
2031
+ if (
2032
+ routeServerOrInfra &&
2033
+ containsCallExpression(source, "createSecurityHeadersHooks")
2034
+ ) {
2035
+ hasSecurityHeadersHook = true;
2036
+ }
2011
2037
 
2012
2038
  if (
2013
2039
  containsCallExpression(source, "createDevtoolsRoute") &&
@@ -2063,10 +2089,7 @@ async function inspectProductionReadiness(
2063
2089
  });
2064
2090
  }
2065
2091
 
2066
- if (
2067
- isRouteServerOrInfraFile(file, config) &&
2068
- containsRequestTenantHeaderAuthority(source)
2069
- ) {
2092
+ if (routeServerOrInfra && containsRequestTenantHeaderAuthority(source)) {
2070
2093
  diagnostics.push({
2071
2094
  severity: "warning",
2072
2095
  code: "BEIGNET_TENANT_HEADER_AUTHORITY",
@@ -2076,7 +2099,7 @@ async function inspectProductionReadiness(
2076
2099
  }
2077
2100
 
2078
2101
  if (
2079
- isRouteServerOrInfraFile(file, config) &&
2102
+ routeServerOrInfra &&
2080
2103
  containsCallExpression(source, "createCorsHooks") &&
2081
2104
  /\bcredentials\s*:\s*true\b/.test(source) &&
2082
2105
  /(?:\borigin\b|\borigins\b)\s*:\s*(?:["']\*["']|\[\s*["']\*["'])/.test(
@@ -2092,6 +2115,15 @@ async function inspectProductionReadiness(
2092
2115
  }
2093
2116
  }
2094
2117
 
2118
+ if (files.includes(config.paths.server) && !hasSecurityHeadersHook) {
2119
+ diagnostics.push({
2120
+ severity: "warning",
2121
+ code: "BEIGNET_SECURITY_HEADERS_MISSING",
2122
+ file: config.paths.server,
2123
+ message: `${config.paths.server} does not install createSecurityHeadersHooks(...). Add the hook or equivalent platform-managed headers before exposing browser traffic.`,
2124
+ });
2125
+ }
2126
+
2095
2127
  for (const file of configFiles) {
2096
2128
  const source = await readCachedSource(targetDir, file, sourceCache);
2097
2129
 
@@ -2397,7 +2429,7 @@ async function inspectPaymentsProductionReadiness(
2397
2429
  code: "BEIGNET_PAYMENTS_STRIPE_MEMORY_PROVIDER",
2398
2430
  file: serverProviderFiles(files, config)[0] ?? config.paths.server,
2399
2431
  message:
2400
- "@beignet/provider-payments-stripe is installed and Stripe env/config is present, but server/providers.ts still registers memory payments. Register stripePaymentsProvider before deploying Stripe-backed billing, or remove the Stripe provider/config until the app is ready to switch.",
2432
+ "@beignet/provider-payments-stripe is installed and Stripe env/config is present, but server/providers.ts still registers memory payments. Register createStripePaymentsProvider() before deploying Stripe-backed billing, or remove the Stripe provider/config until the app is ready to switch.",
2401
2433
  });
2402
2434
  }
2403
2435
 
@@ -2499,7 +2531,8 @@ async function hasStripeMemoryProviderMismatch(
2499
2531
  return false;
2500
2532
  }
2501
2533
  if (
2502
- findProviderEntryIndex(providerEntries, ["stripePaymentsProvider"]) >= 0
2534
+ findProviderEntryIndex(providerEntries, ["createStripePaymentsProvider"]) >=
2535
+ 0
2503
2536
  ) {
2504
2537
  return false;
2505
2538
  }
@@ -3486,6 +3519,92 @@ async function inspectWorkflowRegistrationDrift(
3486
3519
  return diagnostics;
3487
3520
  }
3488
3521
 
3522
+ type RuntimeManifestIdentifierSets = {
3523
+ file: string;
3524
+ listeners: Set<string>;
3525
+ schedules: Set<string>;
3526
+ tasks: Set<string>;
3527
+ events: Set<string>;
3528
+ jobs: Set<string>;
3529
+ };
3530
+
3531
+ async function inspectRuntimeManifestDrift(
3532
+ targetDir: string,
3533
+ files: string[],
3534
+ config: ResolvedBeignetConfig,
3535
+ convention: InspectConvention,
3536
+ ): Promise<InspectDiagnostic[]> {
3537
+ if (!convention.resourceGenerator) return [];
3538
+
3539
+ const manifest = await readRuntimeManifestIdentifiers(targetDir, files);
3540
+ if (!manifest) return [];
3541
+
3542
+ const registries = await readFeatureWorkflowRegistries(
3543
+ targetDir,
3544
+ files,
3545
+ config,
3546
+ );
3547
+ const diagnostics: InspectDiagnostic[] = [];
3548
+
3549
+ const checks: readonly {
3550
+ kind: WorkflowRegistryKind;
3551
+ registered: Set<string>;
3552
+ code: string;
3553
+ label: string;
3554
+ }[] = [
3555
+ {
3556
+ kind: "listeners",
3557
+ registered: manifest.listeners,
3558
+ code: "BEIGNET_RUNTIME_MANIFEST_LISTENER_MISSING",
3559
+ label: "listeners",
3560
+ },
3561
+ {
3562
+ kind: "schedules",
3563
+ registered: manifest.schedules,
3564
+ code: "BEIGNET_RUNTIME_MANIFEST_SCHEDULE_MISSING",
3565
+ label: "schedules",
3566
+ },
3567
+ {
3568
+ kind: "tasks",
3569
+ registered: manifest.tasks,
3570
+ code: "BEIGNET_RUNTIME_MANIFEST_TASK_MISSING",
3571
+ label: "tasks",
3572
+ },
3573
+ {
3574
+ kind: "events",
3575
+ registered: manifest.events,
3576
+ code: "BEIGNET_RUNTIME_MANIFEST_OUTBOX_EVENT_MISSING",
3577
+ label: "outbox events",
3578
+ },
3579
+ {
3580
+ kind: "jobs",
3581
+ registered: manifest.jobs,
3582
+ code: "BEIGNET_RUNTIME_MANIFEST_OUTBOX_JOB_MISSING",
3583
+ label: "outbox jobs",
3584
+ },
3585
+ ];
3586
+
3587
+ for (const check of checks) {
3588
+ const missing = unregisteredWorkflowRegistries(
3589
+ registries.filter((registry) => registry.kind === check.kind),
3590
+ check.registered,
3591
+ );
3592
+
3593
+ for (const { registry, missingMembers } of missing) {
3594
+ for (const member of missingMembers) {
3595
+ diagnostics.push({
3596
+ severity: "warning",
3597
+ code: check.code,
3598
+ file: manifest.file,
3599
+ message: `${workflowArtifactOrigin(registry, member)}, but it is not listed in defineRuntimeManifest(...) ${check.label} in ${manifest.file}, so runtime integrity cannot fail fast if it is omitted from a runtime registry.`,
3600
+ });
3601
+ }
3602
+ }
3603
+ }
3604
+
3605
+ return diagnostics;
3606
+ }
3607
+
3489
3608
  function workflowArtifactOrigin(
3490
3609
  registry: FeatureWorkflowRegistry,
3491
3610
  member: string,
@@ -3592,7 +3711,7 @@ async function workflowRegistrationDrift(
3592
3711
 
3593
3712
  const listenerRegistries = byKind("listeners");
3594
3713
  if (listenerRegistries.length > 0) {
3595
- const wiring = await listenerWiringReferences(targetDir, files);
3714
+ const wiring = await listenerWiringReferences(targetDir, files, config);
3596
3715
  drift.listeners = {
3597
3716
  unregistered: unregisteredWorkflowRegistries(
3598
3717
  listenerRegistries,
@@ -3708,6 +3827,86 @@ function exportedMemberFiles(
3708
3827
  return memberFiles;
3709
3828
  }
3710
3829
 
3830
+ async function readRuntimeManifestIdentifiers(
3831
+ targetDir: string,
3832
+ files: string[],
3833
+ ): Promise<RuntimeManifestIdentifierSets | undefined> {
3834
+ for (const file of files) {
3835
+ if (!file.endsWith(".ts") && !file.endsWith(".tsx")) continue;
3836
+ if (file.endsWith(".d.ts") || isWorkflowTestFile(file)) continue;
3837
+
3838
+ const source = await readFile(path.join(targetDir, file), "utf8");
3839
+ if (!source.includes("defineRuntimeManifest")) continue;
3840
+
3841
+ const identifiers = runtimeManifestIdentifiers(source);
3842
+ if (identifiers) {
3843
+ return {
3844
+ file,
3845
+ ...identifiers,
3846
+ };
3847
+ }
3848
+ }
3849
+
3850
+ return undefined;
3851
+ }
3852
+
3853
+ function runtimeManifestIdentifiers(
3854
+ source: string,
3855
+ ): Omit<RuntimeManifestIdentifierSets, "file"> | undefined {
3856
+ const call = /\bdefineRuntimeManifest\s*(?:<[^>]*>\s*)?\(/.exec(source);
3857
+ if (!call) return undefined;
3858
+
3859
+ const arg = firstCallArgInfo(source, call.index + call[0].length);
3860
+ if (!arg) return undefined;
3861
+
3862
+ const outbox = objectPropertyValueInfo(arg.text, "outbox");
3863
+
3864
+ return {
3865
+ listeners: identifiersFromPropertyArray(arg.text, "listeners"),
3866
+ schedules: identifiersFromPropertyArray(arg.text, "schedules"),
3867
+ tasks: identifiersFromPropertyArray(arg.text, "tasks"),
3868
+ events: outbox
3869
+ ? identifiersFromPropertyArray(outbox.text, "events")
3870
+ : new Set<string>(),
3871
+ jobs: outbox
3872
+ ? identifiersFromPropertyArray(outbox.text, "jobs")
3873
+ : new Set<string>(),
3874
+ };
3875
+ }
3876
+
3877
+ function identifiersFromPropertyArray(
3878
+ source: string,
3879
+ property: string,
3880
+ ): Set<string> {
3881
+ const value = objectPropertyValueInfo(source, property);
3882
+ if (!value?.text.startsWith("[")) return new Set<string>();
3883
+ return identifiersFromArrayExpression(value.text);
3884
+ }
3885
+
3886
+ function objectPropertyValueInfo(
3887
+ source: string,
3888
+ property: string,
3889
+ ): { text: string; start: number; end: number } | undefined {
3890
+ const propertyMatch = new RegExp(`\\b${property}\\s*:`).exec(source);
3891
+ if (!propertyMatch) return undefined;
3892
+
3893
+ let start = propertyMatch.index + propertyMatch[0].length;
3894
+ while (start < source.length && /\s/.test(source[start])) start++;
3895
+
3896
+ const open = source[start];
3897
+ const close = open === "[" ? "]" : open === "{" ? "}" : undefined;
3898
+ if (!close) return undefined;
3899
+
3900
+ const end = matchingDelimiterIndex(source, start, open, close);
3901
+ if (end === -1) return undefined;
3902
+
3903
+ return {
3904
+ text: source.slice(start, end + 1),
3905
+ start,
3906
+ end: end + 1,
3907
+ };
3908
+ }
3909
+
3711
3910
  function registeredWorkflowIdentifiers(
3712
3911
  source: string,
3713
3912
  kind: "schedules" | "tasks",
@@ -3755,6 +3954,7 @@ async function listenedEventNames(
3755
3954
  async function listenerWiringReferences(
3756
3955
  targetDir: string,
3757
3956
  files: string[],
3957
+ config: ResolvedBeignetConfig,
3758
3958
  ): Promise<{
3759
3959
  identifiers: Set<string>;
3760
3960
  wiringFile?: string;
@@ -3763,12 +3963,15 @@ async function listenerWiringReferences(
3763
3963
  const identifiers = new Set<string>();
3764
3964
  let wiringFile: string | undefined;
3765
3965
  let eventBusFile: string | undefined;
3966
+ let centralListenerRegistryReferenced = false;
3967
+ const listenerRegistryFile = listenersFilePath(config);
3766
3968
 
3767
3969
  for (const file of files) {
3768
3970
  if (!file.endsWith(".ts") && !file.endsWith(".tsx")) continue;
3769
3971
  if (file.endsWith(".d.ts") || isWorkflowTestFile(file)) continue;
3770
3972
 
3771
3973
  const source = await readFile(path.join(targetDir, file), "utf8");
3974
+ const namedImports = parseNamedImportSources(source);
3772
3975
  let foundCall = false;
3773
3976
 
3774
3977
  for (const match of source.matchAll(/\bregisterListeners\s*\(/g)) {
@@ -3780,9 +3983,21 @@ async function listenerWiringReferences(
3780
3983
  : source.slice(openParen + 1, closeParen);
3781
3984
 
3782
3985
  foundCall = true;
3783
- for (const identifier of identifiersFromArrayExpression(argsText)) {
3986
+ const callIdentifiers = identifiersFromArrayExpression(argsText);
3987
+ for (const identifier of callIdentifiers) {
3784
3988
  identifiers.add(identifier);
3785
3989
  }
3990
+ if (
3991
+ callReferencesCentralListenerRegistry({
3992
+ callIdentifiers,
3993
+ namedImports,
3994
+ importerFile: file,
3995
+ listenerRegistryFile,
3996
+ files,
3997
+ })
3998
+ ) {
3999
+ centralListenerRegistryReferenced = true;
4000
+ }
3786
4001
  }
3787
4002
 
3788
4003
  if (foundCall) {
@@ -3792,9 +4007,54 @@ async function listenerWiringReferences(
3792
4007
  }
3793
4008
  }
3794
4009
 
4010
+ if (
4011
+ centralListenerRegistryReferenced &&
4012
+ files.includes(listenerRegistryFile)
4013
+ ) {
4014
+ const source = await readFile(
4015
+ path.join(targetDir, listenerRegistryFile),
4016
+ "utf8",
4017
+ );
4018
+ const central = arrayInitializerInfo(source, "listeners");
4019
+ if (central) {
4020
+ for (const identifier of identifiersFromArrayExpression(central.text)) {
4021
+ identifiers.add(identifier);
4022
+ }
4023
+ }
4024
+ }
4025
+
3795
4026
  return { identifiers, wiringFile, eventBusFile };
3796
4027
  }
3797
4028
 
4029
+ function callReferencesCentralListenerRegistry(args: {
4030
+ callIdentifiers: Set<string>;
4031
+ namedImports: Map<string, { importedName: string; sourcePath: string }>;
4032
+ importerFile: string;
4033
+ listenerRegistryFile: string;
4034
+ files: string[];
4035
+ }): boolean {
4036
+ if (
4037
+ args.importerFile === args.listenerRegistryFile &&
4038
+ args.callIdentifiers.has("listeners")
4039
+ ) {
4040
+ return true;
4041
+ }
4042
+
4043
+ for (const identifier of args.callIdentifiers) {
4044
+ const imported = args.namedImports.get(identifier);
4045
+ if (!imported || imported.importedName !== "listeners") continue;
4046
+
4047
+ const importedFile = sourceFileFromImport(
4048
+ imported.sourcePath,
4049
+ args.importerFile,
4050
+ args.files,
4051
+ );
4052
+ if (importedFile === args.listenerRegistryFile) return true;
4053
+ }
4054
+
4055
+ return false;
4056
+ }
4057
+
3798
4058
  function isWorkflowTestFile(file: string): boolean {
3799
4059
  return (
3800
4060
  file.endsWith(".test.ts") ||
@@ -4115,6 +4375,15 @@ async function inspectDatabaseLifecycleDrift(
4115
4375
  schemaDir,
4116
4376
  sourceCache,
4117
4377
  )),
4378
+ ...(await inspectTenantScopeRepositoryDrift(
4379
+ targetDir,
4380
+ files,
4381
+ resources,
4382
+ config,
4383
+ infrastructurePath,
4384
+ schemaDir,
4385
+ sourceCache,
4386
+ )),
4118
4387
  );
4119
4388
  }
4120
4389
 
@@ -4274,119 +4543,700 @@ async function inspectDatabaseLifecycleDrift(
4274
4543
  return diagnostics;
4275
4544
  }
4276
4545
 
4277
- type DurableDrizzleTableRequirement = {
4278
- capability: string;
4279
- table: keyof BeignetDatabaseTables;
4280
- code: string;
4281
- factoryNames: readonly string[];
4282
- setupNames: readonly string[];
4283
- explicitFactoryPattern: string;
4284
- explicitSetupPattern: string;
4285
- };
4286
-
4287
- const durableDrizzleTableRequirements: readonly DurableDrizzleTableRequirement[] =
4288
- [
4289
- {
4290
- capability: "idempotency",
4291
- table: "idempotency",
4292
- code: "BEIGNET_IDEMPOTENCY_TABLE_MISSING",
4293
- factoryNames: [
4294
- "createDrizzleSqliteIdempotencyPort",
4295
- "createDrizzlePostgresIdempotencyPort",
4296
- "createDrizzleMysqlIdempotencyPort",
4297
- ],
4298
- setupNames: [
4299
- "createDrizzleSqliteIdempotencySetupStatements",
4300
- "createDrizzlePostgresIdempotencySetupStatements",
4301
- "createDrizzleMysqlIdempotencySetupStatements",
4302
- ],
4303
- explicitFactoryPattern: "createDrizzle[A-Za-z]*Idempotency(?:Port)?",
4304
- explicitSetupPattern: "createDrizzle[A-Za-z]*IdempotencySetupStatements",
4305
- },
4306
- {
4307
- capability: "outbox",
4308
- table: "outbox",
4309
- code: "BEIGNET_OUTBOX_TABLE_MISSING",
4310
- factoryNames: [
4311
- "createDrizzleSqliteOutboxPort",
4312
- "createDrizzlePostgresOutboxPort",
4313
- "createDrizzleMysqlOutboxPort",
4314
- ],
4315
- setupNames: [
4316
- "createDrizzleSqliteOutboxSetupStatements",
4317
- "createDrizzlePostgresOutboxSetupStatements",
4318
- "createDrizzleMysqlOutboxSetupStatements",
4319
- ],
4320
- explicitFactoryPattern: "createDrizzle[A-Za-z]*Outbox(?:Port)?",
4321
- explicitSetupPattern: "createDrizzle[A-Za-z]*OutboxSetupStatements",
4322
- },
4323
- {
4324
- capability: "audit",
4325
- table: "audit",
4326
- code: "BEIGNET_AUDIT_TABLE_MISSING",
4327
- factoryNames: [
4328
- "createDrizzleSqliteAuditLogPort",
4329
- "createDrizzlePostgresAuditLogPort",
4330
- "createDrizzleMysqlAuditLogPort",
4331
- ],
4332
- setupNames: [
4333
- "createDrizzleSqliteAuditLogSetupStatements",
4334
- "createDrizzlePostgresAuditLogSetupStatements",
4335
- "createDrizzleMysqlAuditLogSetupStatements",
4336
- ],
4337
- explicitFactoryPattern: "createDrizzle[A-Za-z]*AuditLog(?:Port)?",
4338
- explicitSetupPattern: "createDrizzle[A-Za-z]*AuditLogSetupStatements",
4339
- },
4340
- ];
4341
-
4342
- async function inspectDurableDrizzleTableDrift(
4546
+ async function inspectTenantScopeRepositoryDrift(
4343
4547
  targetDir: string,
4344
4548
  files: string[],
4549
+ resources: string[],
4345
4550
  config: ResolvedBeignetConfig,
4346
4551
  infrastructurePath: string,
4347
4552
  schemaDir: string,
4348
4553
  sourceCache: Map<string, string>,
4349
4554
  ): Promise<InspectDiagnostic[]> {
4350
4555
  const diagnostics: InspectDiagnostic[] = [];
4351
- const usageFiles = durableDrizzleUsageFiles(files, config);
4556
+ const schemaIndexFile = `${schemaDir}/index.ts`;
4557
+ const schemaFiles = files.filter(
4558
+ (file) =>
4559
+ file.startsWith(`${schemaDir}/`) &&
4560
+ file.endsWith(".ts") &&
4561
+ file !== schemaIndexFile,
4562
+ );
4563
+ const tenantScopedTables = await collectTenantScopedDrizzleTables(
4564
+ targetDir,
4565
+ schemaFiles,
4566
+ sourceCache,
4567
+ );
4568
+ const inspectedRepositories = new Set<string>();
4352
4569
 
4353
- for (const requirement of durableDrizzleTableRequirements) {
4354
- const tableNames = await configuredDurableDrizzleTableNames(
4570
+ for (const resource of resources) {
4571
+ const singular = singularize(resource);
4572
+ const singularPascal = pascalCase(singular);
4573
+ const pluralCamel = camelCase(resource);
4574
+ const schemaFile = `${schemaDir}/${resource}.ts`;
4575
+ const portFile = resourcePortFile(resource, singular, config);
4576
+ const drizzleRepositoryFile = drizzleRepositoryAdapterFile(
4577
+ files,
4578
+ `${infrastructurePath}/${resource}/`,
4579
+ singular,
4580
+ );
4581
+ if (!drizzleRepositoryFile || !files.includes(schemaFile)) continue;
4582
+
4583
+ const schemaSource = await readCachedSource(
4355
4584
  targetDir,
4356
- usageFiles,
4357
- config,
4358
- requirement,
4585
+ schemaFile,
4359
4586
  sourceCache,
4360
4587
  );
4361
- if (tableNames.size === 0) continue;
4588
+ const schemaTenantColumns = tenantScopeColumnsFromSource(schemaSource);
4589
+ if (schemaTenantColumns.length === 0) continue;
4590
+ inspectedRepositories.add(drizzleRepositoryFile);
4362
4591
 
4363
- for (const tableName of tableNames) {
4364
- if (
4365
- await durableDrizzleTableExists(
4366
- targetDir,
4367
- files,
4368
- config,
4369
- infrastructurePath,
4370
- schemaDir,
4371
- requirement,
4372
- tableName,
4373
- sourceCache,
4374
- )
4375
- ) {
4376
- continue;
4592
+ if (files.includes(portFile)) {
4593
+ const portSource = await readCachedSource(
4594
+ targetDir,
4595
+ portFile,
4596
+ sourceCache,
4597
+ );
4598
+ if (!/\b[A-Za-z_$][\w$]*\s*:\s*TenantScope\b/.test(portSource)) {
4599
+ diagnostics.push({
4600
+ severity: "warning",
4601
+ code: "BEIGNET_TENANT_SCOPE_PORT_MISSING",
4602
+ file: portFile,
4603
+ message: `${resource} has a tenant-scoped Drizzle schema (${schemaFile}), but ${portFile} does not accept TenantScope in its ${singularPascal}Repository methods. Tenant-owned repository methods should require a branded scope argument instead of raw tenant or workspace IDs.`,
4604
+ });
4377
4605
  }
4378
-
4379
- diagnostics.push({
4380
- severity: "warning",
4381
- code: requirement.code,
4382
- file: schemaDir,
4383
- message: `This app wires a Drizzle-backed ${requirement.capability} port, but no ${tableName} table setup was found in ${schemaDir}/, drizzle/, or app database setup files. Add the table to your Drizzle schema/migrations, call the provider setup statements, or remove the stale ${requirement.capability} wiring before deployment.`,
4384
- });
4385
4606
  }
4386
- }
4387
-
4388
- return diagnostics;
4389
- }
4607
+
4608
+ const repositorySource = await readCachedSource(
4609
+ targetDir,
4610
+ drizzleRepositoryFile,
4611
+ sourceCache,
4612
+ );
4613
+ const tenantPredicate = `eq(schema.${pluralCamel}.tenantId, tenantScopeId(scope))`;
4614
+ const tenantPredicateCount = countSourceOccurrences(
4615
+ repositorySource,
4616
+ tenantPredicate,
4617
+ );
4618
+ const setsTenantFromScope = repositorySource.includes(
4619
+ "tenantId: tenantScopeId(scope)",
4620
+ );
4621
+ if (
4622
+ !repositorySource.includes(`schema.${pluralCamel}`) ||
4623
+ (tenantPredicateCount >= 4 && setsTenantFromScope) ||
4624
+ repositoryHasScopedTenantCoverage(repositorySource, [
4625
+ {
4626
+ name: pluralCamel,
4627
+ file: schemaFile,
4628
+ columns: schemaTenantColumns,
4629
+ },
4630
+ ])
4631
+ ) {
4632
+ continue;
4633
+ }
4634
+
4635
+ diagnostics.push({
4636
+ severity: "warning",
4637
+ code: "BEIGNET_TENANT_SCOPE_PREDICATE_MISSING",
4638
+ file: drizzleRepositoryFile,
4639
+ message: `${resource} has a tenant-scoped Drizzle schema (${schemaFile}), but ${drizzleRepositoryFile} does not use TenantScope consistently. Tenant-owned repository adapters should derive tenant or workspace IDs from tenantScopeId(scope) and include them in create values and read, update, and delete predicates.`,
4640
+ });
4641
+ }
4642
+
4643
+ const repositoryFiles = files.filter(
4644
+ (file) =>
4645
+ file.startsWith(`${infrastructurePath}/`) &&
4646
+ file.includes("/drizzle-") &&
4647
+ file.endsWith("-repository.ts") &&
4648
+ !file.endsWith(".test.ts"),
4649
+ );
4650
+ const featuresPath = directoryPath(config.paths.features);
4651
+
4652
+ for (const repositoryFile of repositoryFiles) {
4653
+ if (inspectedRepositories.has(repositoryFile)) continue;
4654
+
4655
+ const repositorySource = await readCachedSource(
4656
+ targetDir,
4657
+ repositoryFile,
4658
+ sourceCache,
4659
+ );
4660
+ const referencedTables = tenantScopedTables.filter((table) =>
4661
+ repositorySource.includes(`schema.${table.name}`),
4662
+ );
4663
+ if (referencedTables.length === 0) continue;
4664
+
4665
+ const repositoryFeature = repositoryFeatureFromPath(
4666
+ repositoryFile,
4667
+ infrastructurePath,
4668
+ );
4669
+ if (!repositoryFeature || isTenantAuthorityFeature(repositoryFeature)) {
4670
+ continue;
4671
+ }
4672
+
4673
+ const portFile = `${featuresPath}/${repositoryFeature}/ports.ts`;
4674
+ if (!files.includes(portFile)) continue;
4675
+
4676
+ const portSource = await readCachedSource(targetDir, portFile, sourceCache);
4677
+ const repositoryBlocks = repositoryInterfaceBlocks(portSource);
4678
+ if (repositoryBlocks.length === 0) continue;
4679
+
4680
+ const hasTenantScopeBoundary = repositoryBlocks.some((block) =>
4681
+ repositoryInterfaceAcceptsTenantScope(block),
4682
+ );
4683
+ const rawBoundaries = repositoryBlocks.flatMap((block) =>
4684
+ rawTenantRepositoryMethodBoundaries(block, referencedTables),
4685
+ );
4686
+
4687
+ if (rawBoundaries.length > 0) {
4688
+ diagnostics.push({
4689
+ severity: "warning",
4690
+ code: "BEIGNET_TENANT_SCOPE_RAW_ID_BOUNDARY",
4691
+ file: portFile,
4692
+ message: `${portFile} declares tenant-owned repository methods (${rawBoundaries.join(
4693
+ ", ",
4694
+ )}) with raw tenant or workspace IDs. Use TenantScope at app-facing repository boundaries and unwrap it with tenantScopeId(scope) inside ${repositoryFile}; keep provider-correlation lookups separate from authorization.`,
4695
+ });
4696
+ continue;
4697
+ }
4698
+
4699
+ if (
4700
+ hasTenantScopeBoundary &&
4701
+ !repositoryHasScopedTenantCoverage(repositorySource, referencedTables)
4702
+ ) {
4703
+ diagnostics.push({
4704
+ severity: "warning",
4705
+ code: "BEIGNET_TENANT_SCOPE_PREDICATE_MISSING",
4706
+ file: repositoryFile,
4707
+ message: `${repositoryFile} adapts tenant-owned tables (${referencedTables
4708
+ .map((table) => table.name)
4709
+ .join(
4710
+ ", ",
4711
+ )}), but its scoped methods do not consistently derive tenant or workspace IDs from tenantScopeId(scope). Include that derived ID in create values and read, update, and delete predicates.`,
4712
+ });
4713
+ }
4714
+ }
4715
+
4716
+ return diagnostics;
4717
+ }
4718
+
4719
+ const tenantScopeColumnDefinitions = [
4720
+ {
4721
+ property: "tenantId",
4722
+ sqlName: "tenant_id",
4723
+ label: "tenant",
4724
+ },
4725
+ {
4726
+ property: "workspaceId",
4727
+ sqlName: "workspace_id",
4728
+ label: "workspace",
4729
+ },
4730
+ ] as const;
4731
+
4732
+ type TenantScopeColumn = (typeof tenantScopeColumnDefinitions)[number];
4733
+
4734
+ type TenantScopedDrizzleTable = {
4735
+ name: string;
4736
+ file: string;
4737
+ columns: TenantScopeColumn[];
4738
+ };
4739
+
4740
+ async function collectTenantScopedDrizzleTables(
4741
+ targetDir: string,
4742
+ schemaFiles: string[],
4743
+ sourceCache: Map<string, string>,
4744
+ ): Promise<TenantScopedDrizzleTable[]> {
4745
+ const tables: TenantScopedDrizzleTable[] = [];
4746
+
4747
+ for (const file of schemaFiles) {
4748
+ const source = await readCachedSource(targetDir, file, sourceCache);
4749
+ for (const table of drizzleTablesFromSource(source, file)) {
4750
+ if (table.columns.length > 0) tables.push(table);
4751
+ }
4752
+ }
4753
+
4754
+ return tables;
4755
+ }
4756
+
4757
+ function drizzleTablesFromSource(
4758
+ source: string,
4759
+ file: string,
4760
+ ): TenantScopedDrizzleTable[] {
4761
+ const tables: TenantScopedDrizzleTable[] = [];
4762
+ const tablePattern =
4763
+ /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:sqliteTable|pgTable|mysqlTable)\s*\(/g;
4764
+
4765
+ for (const match of source.matchAll(tablePattern)) {
4766
+ const tableName = match[1];
4767
+ const openIndex = source.indexOf("(", match.index ?? 0);
4768
+ if (!tableName || openIndex === -1) continue;
4769
+
4770
+ const closeIndex = matchingDelimiterIndex(source, openIndex, "(", ")");
4771
+ if (closeIndex === -1) continue;
4772
+
4773
+ const tableSource = source.slice(openIndex, closeIndex + 1);
4774
+ const columns = tenantScopeColumnsFromSource(tableSource);
4775
+ tables.push({ name: tableName, file, columns });
4776
+ }
4777
+
4778
+ return tables;
4779
+ }
4780
+
4781
+ function tenantScopeColumnsFromSource(source: string): TenantScopeColumn[] {
4782
+ return tenantScopeColumnDefinitions.filter((column) => {
4783
+ const propertyPattern = new RegExp(`\\b${column.property}\\s*:`);
4784
+ return (
4785
+ propertyPattern.test(source) ||
4786
+ source.includes(`"${column.sqlName}"`) ||
4787
+ source.includes(`'${column.sqlName}'`)
4788
+ );
4789
+ });
4790
+ }
4791
+
4792
+ function repositoryFeatureFromPath(
4793
+ repositoryFile: string,
4794
+ infrastructurePath: string,
4795
+ ): string | undefined {
4796
+ const match = new RegExp(
4797
+ `^${escapeRegExp(infrastructurePath)}/([^/]+)/`,
4798
+ ).exec(repositoryFile);
4799
+ return match?.[1];
4800
+ }
4801
+
4802
+ function isTenantAuthorityFeature(feature: string): boolean {
4803
+ return feature === "workspaces";
4804
+ }
4805
+
4806
+ function repositoryInterfaceBlocks(source: string): string[] {
4807
+ const blocks: string[] = [];
4808
+ const interfacePattern =
4809
+ /\bexport\s+interface\s+[A-Za-z_$][\w$]*Repository\s*\{/g;
4810
+
4811
+ for (const match of source.matchAll(interfacePattern)) {
4812
+ const openIndex = source.indexOf("{", match.index ?? 0);
4813
+ if (openIndex === -1) continue;
4814
+
4815
+ const closeIndex = matchingDelimiterIndex(source, openIndex, "{", "}");
4816
+ if (closeIndex === -1) continue;
4817
+
4818
+ blocks.push(source.slice(openIndex + 1, closeIndex));
4819
+ }
4820
+
4821
+ return blocks;
4822
+ }
4823
+
4824
+ function repositoryInterfaceAcceptsTenantScope(block: string): boolean {
4825
+ return /\b[A-Za-z_$][\w$]*\s*:\s*TenantScope\b/.test(block);
4826
+ }
4827
+
4828
+ function rawTenantRepositoryMethodBoundaries(
4829
+ block: string,
4830
+ tables: readonly TenantScopedDrizzleTable[],
4831
+ ): string[] {
4832
+ const rawMethods: string[] = [];
4833
+ for (const method of repositoryMethodSignatures(block)) {
4834
+ if (isProviderCorrelationMethod(method.name)) continue;
4835
+
4836
+ const rawColumns = tenantColumnsUsedInSource(method.params, tables);
4837
+ if (rawColumns.length === 0) continue;
4838
+
4839
+ rawMethods.push(method.name);
4840
+ }
4841
+
4842
+ return uniqueStringValues(rawMethods);
4843
+ }
4844
+
4845
+ function repositoryMethodSignatures(
4846
+ block: string,
4847
+ ): Array<{ name: string; params: string }> {
4848
+ const signatures: Array<{ name: string; params: string }> = [];
4849
+ const methodPattern = /\b([A-Za-z_$][\w$]*)\s*\(/g;
4850
+
4851
+ for (const match of block.matchAll(methodPattern)) {
4852
+ const name = match[1];
4853
+ const openIndex = block.indexOf("(", match.index ?? 0);
4854
+ if (!name || openIndex === -1) continue;
4855
+
4856
+ const closeIndex = matchingDelimiterIndex(block, openIndex, "(", ")");
4857
+ if (closeIndex === -1) continue;
4858
+
4859
+ const afterParams = block.slice(closeIndex + 1).trimStart();
4860
+ if (!afterParams.startsWith(":")) continue;
4861
+
4862
+ signatures.push({
4863
+ name,
4864
+ params: block.slice(openIndex + 1, closeIndex),
4865
+ });
4866
+ }
4867
+
4868
+ return signatures;
4869
+ }
4870
+
4871
+ function isProviderCorrelationMethod(name: string): boolean {
4872
+ return /(?:Customer|Provider|Subscription|Checkout|Event|Delivery|External|Token)[A-Za-z0-9]*(?:Id)?$/i.test(
4873
+ name,
4874
+ );
4875
+ }
4876
+
4877
+ function tenantColumnsUsedInSource(
4878
+ source: string,
4879
+ tables: readonly TenantScopedDrizzleTable[],
4880
+ ): TenantScopeColumn[] {
4881
+ const columns = new Map<string, TenantScopeColumn>();
4882
+ for (const table of tables) {
4883
+ for (const column of table.columns) {
4884
+ const propertyPattern = new RegExp(`\\b${column.property}\\s*:`);
4885
+ if (propertyPattern.test(source)) {
4886
+ columns.set(column.property, column);
4887
+ }
4888
+ }
4889
+ }
4890
+
4891
+ return [...columns.values()];
4892
+ }
4893
+
4894
+ function repositoryHasScopedTenantCoverage(
4895
+ source: string,
4896
+ tables: readonly TenantScopedDrizzleTable[],
4897
+ ): boolean {
4898
+ if (!source.includes("tenantScopeId(scope)")) return false;
4899
+
4900
+ const allFunctions = functionLikeBlocks(source);
4901
+ const scopedFunctions = allFunctions.filter((block) =>
4902
+ /\bscope\b/.test(block.params),
4903
+ );
4904
+ if (scopedFunctions.length === 0) return false;
4905
+
4906
+ const functionsByName = new Map(
4907
+ allFunctions.map((block) => [block.name, block]),
4908
+ );
4909
+
4910
+ return scopedFunctions.every((block) =>
4911
+ scopedFunctionUsesTenantScope(block, tables, functionsByName),
4912
+ );
4913
+ }
4914
+
4915
+ type FunctionLikeBlock = {
4916
+ name: string;
4917
+ params: string;
4918
+ body: string;
4919
+ };
4920
+
4921
+ function functionLikeBlocks(source: string): FunctionLikeBlock[] {
4922
+ const blocks: FunctionLikeBlock[] = [];
4923
+ const pattern = /(?:\basync\s+)?(?:function\s+)?([A-Za-z_$][\w$]*)\s*\(/g;
4924
+
4925
+ for (const match of source.matchAll(pattern)) {
4926
+ const name = match[1];
4927
+ const openParamsIndex = source.indexOf("(", match.index ?? 0);
4928
+ if (!name || openParamsIndex === -1) continue;
4929
+
4930
+ const closeParamsIndex = matchingDelimiterIndex(
4931
+ source,
4932
+ openParamsIndex,
4933
+ "(",
4934
+ ")",
4935
+ );
4936
+ if (closeParamsIndex === -1) continue;
4937
+
4938
+ const afterParams = source.slice(closeParamsIndex + 1);
4939
+ const bodyOpenOffset = afterParams.search(/\{/);
4940
+ if (bodyOpenOffset === -1) continue;
4941
+
4942
+ const betweenParamsAndBody = afterParams.slice(0, bodyOpenOffset);
4943
+ if (
4944
+ !/^\s*(?::[^{;=]+)?\s*(?:=>\s*)?$/.test(betweenParamsAndBody) &&
4945
+ !/^\s*$/.test(betweenParamsAndBody)
4946
+ ) {
4947
+ continue;
4948
+ }
4949
+
4950
+ const openBodyIndex = closeParamsIndex + 1 + bodyOpenOffset;
4951
+ const closeBodyIndex = matchingDelimiterIndex(
4952
+ source,
4953
+ openBodyIndex,
4954
+ "{",
4955
+ "}",
4956
+ );
4957
+ if (closeBodyIndex === -1) continue;
4958
+
4959
+ blocks.push({
4960
+ name,
4961
+ params: source.slice(openParamsIndex + 1, closeParamsIndex),
4962
+ body: source.slice(openBodyIndex + 1, closeBodyIndex),
4963
+ });
4964
+ }
4965
+
4966
+ return blocks;
4967
+ }
4968
+
4969
+ function scopedFunctionUsesTenantScope(
4970
+ block: FunctionLikeBlock,
4971
+ tables: readonly TenantScopedDrizzleTable[],
4972
+ functionsByName: ReadonlyMap<string, FunctionLikeBlock>,
4973
+ ): boolean {
4974
+ if (bodyUsesTenantScopePredicate(block.body, tables)) return true;
4975
+
4976
+ for (const variable of tenantScopeVariables(block.body)) {
4977
+ if (bodyUsesTenantScopePredicate(block.body, tables, variable)) return true;
4978
+
4979
+ for (const callee of functionsCalledWithVariable(block.body, variable)) {
4980
+ const helper = functionsByName.get(callee.name);
4981
+ if (
4982
+ helper &&
4983
+ helperUsesTenantVariable(helper, tables, callee.argumentIndex)
4984
+ ) {
4985
+ return true;
4986
+ }
4987
+ }
4988
+ }
4989
+
4990
+ return false;
4991
+ }
4992
+
4993
+ function tenantScopeVariables(body: string): string[] {
4994
+ const variables = new Set<string>(["tenantScopeId(scope)"]);
4995
+ const assignmentPattern =
4996
+ /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*tenantScopeId\(scope\)/g;
4997
+
4998
+ for (const match of body.matchAll(assignmentPattern)) {
4999
+ if (match[1]) variables.add(match[1]);
5000
+ }
5001
+
5002
+ return [...variables];
5003
+ }
5004
+
5005
+ function bodyUsesTenantScopePredicate(
5006
+ body: string,
5007
+ tables: readonly TenantScopedDrizzleTable[],
5008
+ variable = "tenantScopeId(scope)",
5009
+ ): boolean {
5010
+ for (const table of tables) {
5011
+ for (const column of table.columns) {
5012
+ if (
5013
+ body.includes(
5014
+ `eq(schema.${table.name}.${column.property}, ${variable})`,
5015
+ )
5016
+ ) {
5017
+ return true;
5018
+ }
5019
+ if (body.includes(`${column.property}: ${variable}`)) {
5020
+ return true;
5021
+ }
5022
+ if (
5023
+ variable === column.property &&
5024
+ new RegExp(`\\b${column.property}\\s*(?:,|})`).test(body)
5025
+ ) {
5026
+ return true;
5027
+ }
5028
+ }
5029
+ }
5030
+
5031
+ return false;
5032
+ }
5033
+
5034
+ function functionsCalledWithVariable(
5035
+ body: string,
5036
+ variable: string,
5037
+ ): Array<{ name: string; argumentIndex: number }> {
5038
+ if (variable.includes("(")) return [];
5039
+
5040
+ const calls: Array<{ name: string; argumentIndex: number }> = [];
5041
+ const callPattern = /\b([A-Za-z_$][\w$]*)\s*\(/g;
5042
+
5043
+ for (const match of body.matchAll(callPattern)) {
5044
+ const name = match[1];
5045
+ const openIndex = body.indexOf("(", match.index ?? 0);
5046
+ if (!name || openIndex === -1) continue;
5047
+
5048
+ const closeIndex = matchingDelimiterIndex(body, openIndex, "(", ")");
5049
+ if (closeIndex === -1) continue;
5050
+
5051
+ const args = splitTopLevelArguments(body.slice(openIndex + 1, closeIndex));
5052
+ const argumentIndex = args.findIndex(
5053
+ (argument) => argument.trim() === variable,
5054
+ );
5055
+ if (argumentIndex !== -1) {
5056
+ calls.push({ name, argumentIndex });
5057
+ }
5058
+ }
5059
+
5060
+ return calls;
5061
+ }
5062
+
5063
+ function helperUsesTenantVariable(
5064
+ helper: FunctionLikeBlock,
5065
+ tables: readonly TenantScopedDrizzleTable[],
5066
+ argumentIndex: number,
5067
+ ): boolean {
5068
+ const params = splitTopLevelArguments(helper.params);
5069
+ const parameter = params[argumentIndex]?.trim();
5070
+ if (!parameter) return false;
5071
+
5072
+ const parameterName = /^([A-Za-z_$][\w$]*)/.exec(parameter)?.[1];
5073
+ return parameterName
5074
+ ? bodyUsesTenantScopePredicate(helper.body, tables, parameterName)
5075
+ : false;
5076
+ }
5077
+
5078
+ function splitTopLevelArguments(source: string): string[] {
5079
+ const args: string[] = [];
5080
+ let start = 0;
5081
+ let depth = 0;
5082
+ let inString: string | undefined;
5083
+ let escaped = false;
5084
+
5085
+ for (let index = 0; index < source.length; index++) {
5086
+ const char = source[index];
5087
+ if (inString) {
5088
+ if (escaped) {
5089
+ escaped = false;
5090
+ } else if (char === "\\") {
5091
+ escaped = true;
5092
+ } else if (char === inString) {
5093
+ inString = undefined;
5094
+ }
5095
+ continue;
5096
+ }
5097
+
5098
+ if (char === '"' || char === "'" || char === "`") {
5099
+ inString = char;
5100
+ continue;
5101
+ }
5102
+ if (char === "(" || char === "{" || char === "[") {
5103
+ depth += 1;
5104
+ continue;
5105
+ }
5106
+ if (char === ")" || char === "}" || char === "]") {
5107
+ depth -= 1;
5108
+ continue;
5109
+ }
5110
+ if (char === "," && depth === 0) {
5111
+ args.push(source.slice(start, index));
5112
+ start = index + 1;
5113
+ }
5114
+ }
5115
+
5116
+ args.push(source.slice(start));
5117
+ return args;
5118
+ }
5119
+
5120
+ function uniqueStringValues(values: readonly string[]): string[] {
5121
+ return [...new Set(values)];
5122
+ }
5123
+
5124
+ type DurableDrizzleTableRequirement = {
5125
+ capability: string;
5126
+ table: keyof BeignetDatabaseTables;
5127
+ code: string;
5128
+ factoryNames: readonly string[];
5129
+ setupNames: readonly string[];
5130
+ explicitFactoryPattern: string;
5131
+ explicitSetupPattern: string;
5132
+ };
5133
+
5134
+ const durableDrizzleTableRequirements: readonly DurableDrizzleTableRequirement[] =
5135
+ [
5136
+ {
5137
+ capability: "idempotency",
5138
+ table: "idempotency",
5139
+ code: "BEIGNET_IDEMPOTENCY_TABLE_MISSING",
5140
+ factoryNames: [
5141
+ "createDrizzleSqliteIdempotencyPort",
5142
+ "createDrizzlePostgresIdempotencyPort",
5143
+ "createDrizzleMysqlIdempotencyPort",
5144
+ ],
5145
+ setupNames: [
5146
+ "createDrizzleSqliteIdempotencySetupStatements",
5147
+ "createDrizzlePostgresIdempotencySetupStatements",
5148
+ "createDrizzleMysqlIdempotencySetupStatements",
5149
+ ],
5150
+ explicitFactoryPattern: "createDrizzle[A-Za-z]*Idempotency(?:Port)?",
5151
+ explicitSetupPattern: "createDrizzle[A-Za-z]*IdempotencySetupStatements",
5152
+ },
5153
+ {
5154
+ capability: "outbox",
5155
+ table: "outbox",
5156
+ code: "BEIGNET_OUTBOX_TABLE_MISSING",
5157
+ factoryNames: [
5158
+ "createDrizzleSqliteOutboxPort",
5159
+ "createDrizzleSqliteOutboxAdminPort",
5160
+ "createDrizzlePostgresOutboxPort",
5161
+ "createDrizzlePostgresOutboxAdminPort",
5162
+ "createDrizzleMysqlOutboxPort",
5163
+ "createDrizzleMysqlOutboxAdminPort",
5164
+ ],
5165
+ setupNames: [
5166
+ "createDrizzleSqliteOutboxSetupStatements",
5167
+ "createDrizzlePostgresOutboxSetupStatements",
5168
+ "createDrizzleMysqlOutboxSetupStatements",
5169
+ ],
5170
+ explicitFactoryPattern: "createDrizzle[A-Za-z]*Outbox(?:Admin)?(?:Port)?",
5171
+ explicitSetupPattern: "createDrizzle[A-Za-z]*OutboxSetupStatements",
5172
+ },
5173
+ {
5174
+ capability: "audit",
5175
+ table: "audit",
5176
+ code: "BEIGNET_AUDIT_TABLE_MISSING",
5177
+ factoryNames: [
5178
+ "createDrizzleSqliteAuditLogPort",
5179
+ "createDrizzlePostgresAuditLogPort",
5180
+ "createDrizzleMysqlAuditLogPort",
5181
+ ],
5182
+ setupNames: [
5183
+ "createDrizzleSqliteAuditLogSetupStatements",
5184
+ "createDrizzlePostgresAuditLogSetupStatements",
5185
+ "createDrizzleMysqlAuditLogSetupStatements",
5186
+ ],
5187
+ explicitFactoryPattern: "createDrizzle[A-Za-z]*AuditLog(?:Port)?",
5188
+ explicitSetupPattern: "createDrizzle[A-Za-z]*AuditLogSetupStatements",
5189
+ },
5190
+ ];
5191
+
5192
+ async function inspectDurableDrizzleTableDrift(
5193
+ targetDir: string,
5194
+ files: string[],
5195
+ config: ResolvedBeignetConfig,
5196
+ infrastructurePath: string,
5197
+ schemaDir: string,
5198
+ sourceCache: Map<string, string>,
5199
+ ): Promise<InspectDiagnostic[]> {
5200
+ const diagnostics: InspectDiagnostic[] = [];
5201
+ const usageFiles = durableDrizzleUsageFiles(files, config);
5202
+
5203
+ for (const requirement of durableDrizzleTableRequirements) {
5204
+ const tableNames = await configuredDurableDrizzleTableNames(
5205
+ targetDir,
5206
+ usageFiles,
5207
+ config,
5208
+ requirement,
5209
+ sourceCache,
5210
+ );
5211
+ if (tableNames.size === 0) continue;
5212
+
5213
+ for (const tableName of tableNames) {
5214
+ if (
5215
+ await durableDrizzleTableExists(
5216
+ targetDir,
5217
+ files,
5218
+ config,
5219
+ infrastructurePath,
5220
+ schemaDir,
5221
+ requirement,
5222
+ tableName,
5223
+ sourceCache,
5224
+ )
5225
+ ) {
5226
+ continue;
5227
+ }
5228
+
5229
+ diagnostics.push({
5230
+ severity: "warning",
5231
+ code: requirement.code,
5232
+ file: schemaDir,
5233
+ message: `This app wires a Drizzle-backed ${requirement.capability} port, but no ${tableName} table setup was found in ${schemaDir}/, drizzle/, or app database setup files. Add the table to your Drizzle schema/migrations, call the provider setup statements, or remove the stale ${requirement.capability} wiring before deployment.`,
5234
+ });
5235
+ }
5236
+ }
5237
+
5238
+ return diagnostics;
5239
+ }
4390
5240
 
4391
5241
  function durableDrizzleUsageFiles(
4392
5242
  files: string[],
@@ -4761,22 +5611,70 @@ async function inspectPackageScripts(
4761
5611
  await readFile(path.join(targetDir, "package.json"), "utf8"),
4762
5612
  ) as {
4763
5613
  scripts?: Record<string, string>;
5614
+ dependencies?: Record<string, string>;
5615
+ devDependencies?: Record<string, string>;
4764
5616
  };
4765
5617
 
4766
- if (packageJson.scripts?.test) return [];
5618
+ const diagnostics: InspectDiagnostic[] = [];
5619
+ const scripts = packageJson.scripts ?? {};
5620
+ const hasDirectBunTestImports = await appUsesDirectBunTestImports(
5621
+ targetDir,
5622
+ files,
5623
+ );
4767
5624
 
4768
- return [
4769
- {
5625
+ if (!scripts.test) {
5626
+ diagnostics.push({
4770
5627
  severity: "warning",
4771
5628
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
4772
5629
  file: "package.json",
4773
5630
  message:
4774
- 'package.json does not define a test script. Add "test": "bun test" or run beignet doctor --fix.',
4775
- },
4776
- ];
5631
+ 'package.json does not define a test script. Add "test": "tsx lib/beignet-test-runner.ts" or run beignet doctor --fix.',
5632
+ });
5633
+ }
5634
+
5635
+ const legacyScripts = hasDirectBunTestImports
5636
+ ? []
5637
+ : legacyGeneratedScriptNames(scripts);
5638
+ if (legacyScripts.length > 0) {
5639
+ diagnostics.push({
5640
+ severity: "warning",
5641
+ code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
5642
+ file: "package.json",
5643
+ 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.`,
5644
+ });
5645
+ }
5646
+
5647
+ const needsTsx =
5648
+ hasCurrentGeneratedTsxScript(scripts) &&
5649
+ !packageJson.dependencies?.tsx &&
5650
+ !packageJson.devDependencies?.tsx;
5651
+ if (needsTsx) {
5652
+ diagnostics.push({
5653
+ severity: "warning",
5654
+ code: "BEIGNET_PACKAGE_TSX_DEPENDENCY_MISSING",
5655
+ file: "package.json",
5656
+ message:
5657
+ 'package.json uses generated tsx scripts but does not depend on tsx. Add "tsx" to devDependencies or run beignet doctor --fix.',
5658
+ });
5659
+ }
5660
+
5661
+ const missingSupportFiles = missingTestSupportFiles(files);
5662
+ if (
5663
+ scripts.test === currentGeneratedPackageScripts.test &&
5664
+ missingSupportFiles.length > 0
5665
+ ) {
5666
+ diagnostics.push({
5667
+ severity: "warning",
5668
+ code: "BEIGNET_PACKAGE_TEST_SUPPORT_MISSING",
5669
+ file: "package.json",
5670
+ message: `package.json uses the generated test runner, but ${missingSupportFiles.join(", ")} ${missingSupportFiles.length === 1 ? "is" : "are"} missing. Run beignet doctor --fix to restore test support files.`,
5671
+ });
5672
+ }
5673
+
5674
+ return diagnostics;
4777
5675
  }
4778
5676
 
4779
- async function fixMissingTestScript(
5677
+ async function fixPackageScripts(
4780
5678
  targetDir: string,
4781
5679
  files: string[],
4782
5680
  convention: InspectConvention,
@@ -4789,20 +5687,162 @@ async function fixMissingTestScript(
4789
5687
  const original = await readFile(filePath, "utf8");
4790
5688
  const packageJson = JSON.parse(original) as {
4791
5689
  scripts?: Record<string, string>;
5690
+ dependencies?: Record<string, string>;
5691
+ devDependencies?: Record<string, string>;
4792
5692
  };
4793
5693
  packageJson.scripts = packageJson.scripts ?? {};
4794
- if (packageJson.scripts.test) return undefined;
5694
+ const updatedScripts: string[] = [];
5695
+ const addedTestScript = !packageJson.scripts.test;
5696
+ const hasDirectBunTestImports = await appUsesDirectBunTestImports(
5697
+ targetDir,
5698
+ files,
5699
+ );
5700
+
5701
+ if (addedTestScript) {
5702
+ packageJson.scripts.test = currentGeneratedPackageScripts.test;
5703
+ updatedScripts.push("test");
5704
+ }
5705
+
5706
+ if (!hasDirectBunTestImports) {
5707
+ for (const [name, legacyCommand] of Object.entries(
5708
+ legacyGeneratedPackageScripts,
5709
+ )) {
5710
+ if (packageJson.scripts[name] === legacyCommand) {
5711
+ packageJson.scripts[name] = currentGeneratedPackageScripts[name];
5712
+ if (!updatedScripts.includes(name)) updatedScripts.push(name);
5713
+ }
5714
+ }
5715
+ }
5716
+
5717
+ const needsTsx = hasCurrentGeneratedTsxScript(packageJson.scripts);
5718
+ const addedTsx =
5719
+ needsTsx &&
5720
+ !packageJson.dependencies?.tsx &&
5721
+ !packageJson.devDependencies?.tsx;
5722
+ if (addedTsx) {
5723
+ packageJson.devDependencies = packageJson.devDependencies ?? {};
5724
+ packageJson.devDependencies.tsx = externalVersions.tsx;
5725
+ }
4795
5726
 
4796
- packageJson.scripts.test = "bun test";
4797
5727
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
4798
- if (next === original) return undefined;
5728
+ const createdSupportFiles = await createMissingTestSupportFiles(
5729
+ targetDir,
5730
+ files,
5731
+ packageJson.scripts.test === currentGeneratedPackageScripts.test,
5732
+ );
4799
5733
 
4800
- await writeFile(filePath, next);
4801
- return {
4802
- code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
4803
- file: "package.json",
4804
- message: 'Added "test": "bun test".',
4805
- };
5734
+ if (next === original && createdSupportFiles.length === 0) return undefined;
5735
+
5736
+ if (next !== original) await writeFile(filePath, next);
5737
+
5738
+ if (addedTestScript) {
5739
+ return {
5740
+ code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
5741
+ file: "package.json",
5742
+ message:
5743
+ createdSupportFiles.length > 0
5744
+ ? `Added "test": "tsx lib/beignet-test-runner.ts" and ${createdSupportFiles.join(", ")}.`
5745
+ : 'Added "test": "tsx lib/beignet-test-runner.ts".',
5746
+ };
5747
+ }
5748
+
5749
+ if (updatedScripts.length > 0) {
5750
+ return {
5751
+ code: "BEIGNET_PACKAGE_SCRIPT_LEGACY",
5752
+ file: "package.json",
5753
+ message: packageScriptFixMessage(
5754
+ updatedScripts,
5755
+ createdSupportFiles,
5756
+ addedTsx,
5757
+ ),
5758
+ };
5759
+ }
5760
+
5761
+ if (createdSupportFiles.length > 0) {
5762
+ return {
5763
+ code: "BEIGNET_PACKAGE_TEST_SUPPORT_MISSING",
5764
+ file: "package.json",
5765
+ message: `Restored ${createdSupportFiles.join(", ")}.`,
5766
+ };
5767
+ }
5768
+
5769
+ if (addedTsx) {
5770
+ return {
5771
+ code: "BEIGNET_PACKAGE_TSX_DEPENDENCY_MISSING",
5772
+ file: "package.json",
5773
+ message: 'Added "tsx" to devDependencies.',
5774
+ };
5775
+ }
5776
+
5777
+ return undefined;
5778
+ }
5779
+
5780
+ function legacyGeneratedScriptNames(scripts: Record<string, string>): string[] {
5781
+ return Object.entries(legacyGeneratedPackageScripts)
5782
+ .filter(([name, command]) => scripts[name] === command)
5783
+ .map(([name]) => name);
5784
+ }
5785
+
5786
+ async function appUsesDirectBunTestImports(
5787
+ targetDir: string,
5788
+ files: string[],
5789
+ ): Promise<boolean> {
5790
+ for (const file of files) {
5791
+ if (!isTestSourceFile(file)) continue;
5792
+ const source = await readFile(path.join(targetDir, file), "utf8");
5793
+ if (/\bfrom\s*["']bun:test["']/.test(source)) return true;
5794
+ if (/\bimport\s*\(\s*["']bun:test["']\s*\)/.test(source)) return true;
5795
+ }
5796
+ return false;
5797
+ }
5798
+
5799
+ function hasCurrentGeneratedTsxScript(
5800
+ scripts: Record<string, string>,
5801
+ ): boolean {
5802
+ return Object.values(currentGeneratedPackageScripts).some(
5803
+ (command) =>
5804
+ command.startsWith("tsx ") && Object.values(scripts).includes(command),
5805
+ );
5806
+ }
5807
+
5808
+ function missingTestSupportFiles(files: string[]): string[] {
5809
+ return testSupportTemplateFiles
5810
+ .map((file) => file.path)
5811
+ .filter((file) => !files.includes(file));
5812
+ }
5813
+
5814
+ async function createMissingTestSupportFiles(
5815
+ targetDir: string,
5816
+ files: string[],
5817
+ shouldCreate: boolean,
5818
+ ): Promise<string[]> {
5819
+ if (!shouldCreate) return [];
5820
+ const createdSupportFiles: string[] = [];
5821
+ for (const file of testSupportTemplateFiles) {
5822
+ if (files.includes(file.path)) continue;
5823
+ const destination = path.join(targetDir, file.path);
5824
+ await mkdir(path.dirname(destination), { recursive: true });
5825
+ await writeFile(destination, file.content);
5826
+ createdSupportFiles.push(file.path);
5827
+ }
5828
+ return createdSupportFiles;
5829
+ }
5830
+
5831
+ function packageScriptFixMessage(
5832
+ updatedScripts: string[],
5833
+ createdSupportFiles: string[],
5834
+ addedTsx: boolean,
5835
+ ): string {
5836
+ const parts = [
5837
+ `Updated generated package scripts: ${updatedScripts.join(", ")}.`,
5838
+ ];
5839
+ if (createdSupportFiles.length > 0) {
5840
+ parts.push(`Restored ${createdSupportFiles.join(", ")}.`);
5841
+ }
5842
+ if (addedTsx) {
5843
+ parts.push('Added "tsx" to devDependencies.');
5844
+ }
5845
+ return parts.join(" ");
4806
5846
  }
4807
5847
 
4808
5848
  function unmatchedRouteDiagnostic(
@@ -5479,14 +6519,15 @@ async function fixUnregisteredTasks(
5479
6519
  }
5480
6520
 
5481
6521
  /**
5482
- * Wire fully unregistered feature listener registries into the providers
5483
- * file with the same `<registry>Provider` block `beignet make listener`
5484
- * generates, so both write paths stay byte-compatible.
6522
+ * Wire fully unregistered feature listener registries into the central
6523
+ * listener registry and ensure the providers file registers that central
6524
+ * registry with the same block `beignet make listener` generates, so both
6525
+ * write paths stay byte-compatible.
5485
6526
  *
5486
- * Bails without writing when the providers file or its exported array is
5487
- * missing or customized, when the app has no eventBus port to register
5488
- * against, or when the registry name is already imported from another file.
5489
- * The diagnostic stays in every bail case.
6527
+ * Bails without writing when the providers file or central listener registry
6528
+ * is customized beyond the known anchors, when the app has no eventBus port to
6529
+ * register against, or when the registry name is already imported from another
6530
+ * file. The diagnostic stays in every bail case.
5490
6531
  */
5491
6532
  async function fixUnregisteredListeners(
5492
6533
  targetDir: string,
@@ -5522,50 +6563,86 @@ async function fixUnregisteredListeners(
5522
6563
  if (!/\beventBus\s*[:?]/.test(portsSource)) return undefined;
5523
6564
 
5524
6565
  const providersPath = path.join(targetDir, providersFile);
5525
- const original = await readFile(providersPath, "utf8");
5526
- let next = original;
5527
- const registeredNames: string[] = [];
6566
+ const originalProviders = await readFile(providersPath, "utf8");
6567
+ const listenersFile = listenersFilePath(config);
6568
+ const listenersPath = path.join(targetDir, listenersFile);
6569
+ const originalListeners = files.includes(listenersFile)
6570
+ ? await readFile(listenersPath, "utf8")
6571
+ : undefined;
6572
+ let nextListeners = originalListeners;
6573
+ const candidateNames: string[] = [];
5528
6574
 
5529
6575
  for (const { registry } of candidates) {
5530
- const imported = parseNamedImportSources(next).get(registry.registryName);
6576
+ candidateNames.push(registry.registryName);
6577
+ const imported =
6578
+ nextListeners === undefined
6579
+ ? undefined
6580
+ : parseNamedImportSources(nextListeners).get(registry.registryName);
5531
6581
  if (imported) {
5532
6582
  const importedFile = sourceFileFromImport(
5533
6583
  imported.sourcePath,
5534
- providersFile,
6584
+ listenersFile,
5535
6585
  files,
5536
6586
  );
5537
6587
  if (importedFile !== registry.indexFile) return undefined;
5538
6588
  }
5539
6589
 
5540
- const result = wireListenersProviderSource(next, {
5541
- registryName: registry.registryName,
5542
- featureKebab: listenerFeatureKebab(registry.indexFile),
5543
- listenerModule: `@/${modulePath(registry.indexFile)}`,
5544
- config,
5545
- });
6590
+ const listenerModule = `@/${modulePath(registry.indexFile)}`;
6591
+ const result =
6592
+ nextListeners === undefined
6593
+ ? {
6594
+ kind: "updated" as const,
6595
+ source: createListenersRegistrySource({
6596
+ registryName: registry.registryName,
6597
+ listenerModule,
6598
+ }),
6599
+ }
6600
+ : updateListenersRegistrySource(nextListeners, {
6601
+ registryName: registry.registryName,
6602
+ listenerModule,
6603
+ });
5546
6604
  if (result.kind === "missing") return undefined;
5547
6605
  if (result.kind === "updated") {
5548
- next = result.source;
5549
- registeredNames.push(registry.registryName);
6606
+ nextListeners = result.source;
5550
6607
  }
5551
6608
  }
5552
6609
 
5553
- if (next === original || registeredNames.length === 0) return undefined;
6610
+ if (nextListeners === undefined || candidateNames.length === 0) {
6611
+ return undefined;
6612
+ }
5554
6613
 
5555
- await writeFile(providersPath, next);
6614
+ const providerResult = wireListenersProviderSource(originalProviders, {
6615
+ registryName: candidateNames[0] ?? "listeners",
6616
+ featureKebab: "app",
6617
+ listenerModule: `@/${modulePath(listenersFile)}`,
6618
+ config,
6619
+ });
6620
+ if (providerResult.kind === "missing") return undefined;
6621
+
6622
+ const nextProviders =
6623
+ providerResult.kind === "updated"
6624
+ ? providerResult.source
6625
+ : originalProviders;
6626
+
6627
+ if (
6628
+ nextListeners === originalListeners &&
6629
+ nextProviders === originalProviders
6630
+ ) {
6631
+ return undefined;
6632
+ }
6633
+
6634
+ await mkdir(path.dirname(listenersPath), { recursive: true });
6635
+ await writeFile(listenersPath, nextListeners);
6636
+ if (nextProviders !== originalProviders) {
6637
+ await writeFile(providersPath, nextProviders);
6638
+ }
5556
6639
  return {
5557
6640
  code: "BEIGNET_LISTENER_UNREGISTERED",
5558
- file: providersFile,
5559
- message: `Registered ${registeredNames.join(", ")} with registerListeners(...) in ${providersFile}.`,
6641
+ file: listenersFile,
6642
+ message: `Registered ${candidateNames.join(", ")} in ${listenersFile} and wired listeners with registerListeners(...) in ${providersFile}.`,
5560
6643
  };
5561
6644
  }
5562
6645
 
5563
- function listenerFeatureKebab(indexFile: string): string {
5564
- return path.posix.basename(
5565
- path.posix.dirname(path.posix.dirname(normalizePath(indexFile))),
5566
- );
5567
- }
5568
-
5569
6646
  async function fixUnregisteredOutboxEntries(
5570
6647
  targetDir: string,
5571
6648
  files: string[],
@@ -6270,7 +7347,9 @@ async function inspectResourceSlices(
6270
7347
  sourceIncludesAny(portSource, [
6271
7348
  `update(input: Update${singularPascal}Input)`,
6272
7349
  `update(input: Update${singularPascal}RepositoryInput)`,
7350
+ `update(input: Update${singularPascal}Input, scope: TenantScope)`,
6273
7351
  "delete(id: string): Promise<boolean>",
7352
+ "delete(id: string, scope: TenantScope): Promise<boolean>",
6274
7353
  ]);
6275
7354
 
6276
7355
  if (!hasCrudSignals) continue;
@@ -6308,6 +7387,7 @@ async function inspectResourceSlices(
6308
7387
  alternatives: [
6309
7388
  `findById(id: string): Promise<${singularPascal} | null>`,
6310
7389
  `findById(id: string, filter: ${singularPascal}TenantFilter): Promise<${singularPascal} | null>`,
7390
+ `findById(id: string, scope: TenantScope): Promise<${singularPascal} | null>`,
6311
7391
  ],
6312
7392
  },
6313
7393
  {
@@ -6315,6 +7395,7 @@ async function inspectResourceSlices(
6315
7395
  alternatives: [
6316
7396
  `update(input: Update${singularPascal}Input)`,
6317
7397
  `update(input: Update${singularPascal}RepositoryInput)`,
7398
+ `update(input: Update${singularPascal}Input, scope: TenantScope)`,
6318
7399
  ],
6319
7400
  },
6320
7401
  {
@@ -6322,6 +7403,7 @@ async function inspectResourceSlices(
6322
7403
  alternatives: [
6323
7404
  "delete(id: string): Promise<boolean>",
6324
7405
  `delete(id: string, filter: ${singularPascal}TenantFilter): Promise<boolean>`,
7406
+ "delete(id: string, scope: TenantScope): Promise<boolean>",
6325
7407
  ],
6326
7408
  },
6327
7409
  ];
@@ -6464,6 +7546,16 @@ function sourceIncludesAny(
6464
7546
  return source ? snippets.some((snippet) => source.includes(snippet)) : false;
6465
7547
  }
6466
7548
 
7549
+ function countSourceOccurrences(source: string, snippet: string): number {
7550
+ let count = 0;
7551
+ let index = source.indexOf(snippet);
7552
+ while (index !== -1) {
7553
+ count += 1;
7554
+ index = source.indexOf(snippet, index + snippet.length);
7555
+ }
7556
+ return count;
7557
+ }
7558
+
6467
7559
  function sourceIncludesAll(
6468
7560
  source: string,
6469
7561
  snippets: readonly string[],