@beignet/cli 0.0.31 → 0.0.33

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