@beignet/cli 0.0.32 → 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 (89) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +26 -16
  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 +797 -58
  17. package/dist/inspect.js.map +1 -1
  18. package/dist/make/inbox.d.ts.map +1 -1
  19. package/dist/make/inbox.js +5 -3
  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 +58 -29
  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 +44 -10
  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 +6 -4
  30. package/dist/make/tenancy.js.map +1 -1
  31. package/dist/make.d.ts.map +1 -1
  32. package/dist/make.js +129 -55
  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 +46 -0
  42. package/dist/provider-add.js.map +1 -1
  43. package/dist/task.d.ts.map +1 -1
  44. package/dist/task.js +2 -3
  45. package/dist/task.js.map +1 -1
  46. package/dist/templates/agents.d.ts.map +1 -1
  47. package/dist/templates/agents.js +6 -3
  48. package/dist/templates/agents.js.map +1 -1
  49. package/dist/templates/base.js +3 -3
  50. package/dist/templates/base.js.map +1 -1
  51. package/dist/templates/index.d.ts.map +1 -1
  52. package/dist/templates/index.js +2 -0
  53. package/dist/templates/index.js.map +1 -1
  54. package/dist/templates/server.d.ts.map +1 -1
  55. package/dist/templates/server.js +6 -1
  56. package/dist/templates/server.js.map +1 -1
  57. package/dist/templates/shared.d.ts +3 -1
  58. package/dist/templates/shared.d.ts.map +1 -1
  59. package/dist/templates/shared.js +11 -1
  60. package/dist/templates/shared.js.map +1 -1
  61. package/dist/templates/testing.d.ts +5 -0
  62. package/dist/templates/testing.d.ts.map +1 -0
  63. package/dist/templates/testing.js +542 -0
  64. package/dist/templates/testing.js.map +1 -0
  65. package/dist/templates/todos.js +2 -2
  66. package/package.json +3 -2
  67. package/skills/app-structure/SKILL.md +12 -4
  68. package/src/choices.ts +3 -0
  69. package/src/config.ts +2 -0
  70. package/src/db.ts +1 -1
  71. package/src/index.ts +437 -1
  72. package/src/inspect.ts +1247 -154
  73. package/src/make/inbox.ts +5 -3
  74. package/src/make/payments.ts +58 -29
  75. package/src/make/shared.ts +78 -11
  76. package/src/make/tenancy.ts +6 -4
  77. package/src/make.ts +172 -54
  78. package/src/outbox.ts +363 -0
  79. package/src/preflight.ts +1 -1
  80. package/src/provider-add.ts +47 -0
  81. package/src/task.ts +2 -3
  82. package/src/templates/agents.ts +6 -3
  83. package/src/templates/base.ts +3 -3
  84. package/src/templates/index.ts +2 -0
  85. package/src/templates/server.ts +6 -1
  86. package/src/templates/shared.ts +13 -1
  87. package/src/templates/testing.ts +545 -0
  88. package/src/templates/todos.ts +2 -2
  89. 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
 
@@ -3486,6 +3518,92 @@ async function inspectWorkflowRegistrationDrift(
3486
3518
  return diagnostics;
3487
3519
  }
3488
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
+
3489
3607
  function workflowArtifactOrigin(
3490
3608
  registry: FeatureWorkflowRegistry,
3491
3609
  member: string,
@@ -3592,7 +3710,7 @@ async function workflowRegistrationDrift(
3592
3710
 
3593
3711
  const listenerRegistries = byKind("listeners");
3594
3712
  if (listenerRegistries.length > 0) {
3595
- const wiring = await listenerWiringReferences(targetDir, files);
3713
+ const wiring = await listenerWiringReferences(targetDir, files, config);
3596
3714
  drift.listeners = {
3597
3715
  unregistered: unregisteredWorkflowRegistries(
3598
3716
  listenerRegistries,
@@ -3708,6 +3826,86 @@ function exportedMemberFiles(
3708
3826
  return memberFiles;
3709
3827
  }
3710
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
+
3711
3909
  function registeredWorkflowIdentifiers(
3712
3910
  source: string,
3713
3911
  kind: "schedules" | "tasks",
@@ -3755,6 +3953,7 @@ async function listenedEventNames(
3755
3953
  async function listenerWiringReferences(
3756
3954
  targetDir: string,
3757
3955
  files: string[],
3956
+ config: ResolvedBeignetConfig,
3758
3957
  ): Promise<{
3759
3958
  identifiers: Set<string>;
3760
3959
  wiringFile?: string;
@@ -3763,12 +3962,15 @@ async function listenerWiringReferences(
3763
3962
  const identifiers = new Set<string>();
3764
3963
  let wiringFile: string | undefined;
3765
3964
  let eventBusFile: string | undefined;
3965
+ let centralListenerRegistryReferenced = false;
3966
+ const listenerRegistryFile = listenersFilePath(config);
3766
3967
 
3767
3968
  for (const file of files) {
3768
3969
  if (!file.endsWith(".ts") && !file.endsWith(".tsx")) continue;
3769
3970
  if (file.endsWith(".d.ts") || isWorkflowTestFile(file)) continue;
3770
3971
 
3771
3972
  const source = await readFile(path.join(targetDir, file), "utf8");
3973
+ const namedImports = parseNamedImportSources(source);
3772
3974
  let foundCall = false;
3773
3975
 
3774
3976
  for (const match of source.matchAll(/\bregisterListeners\s*\(/g)) {
@@ -3780,9 +3982,21 @@ async function listenerWiringReferences(
3780
3982
  : source.slice(openParen + 1, closeParen);
3781
3983
 
3782
3984
  foundCall = true;
3783
- for (const identifier of identifiersFromArrayExpression(argsText)) {
3985
+ const callIdentifiers = identifiersFromArrayExpression(argsText);
3986
+ for (const identifier of callIdentifiers) {
3784
3987
  identifiers.add(identifier);
3785
3988
  }
3989
+ if (
3990
+ callReferencesCentralListenerRegistry({
3991
+ callIdentifiers,
3992
+ namedImports,
3993
+ importerFile: file,
3994
+ listenerRegistryFile,
3995
+ files,
3996
+ })
3997
+ ) {
3998
+ centralListenerRegistryReferenced = true;
3999
+ }
3786
4000
  }
3787
4001
 
3788
4002
  if (foundCall) {
@@ -3792,9 +4006,54 @@ async function listenerWiringReferences(
3792
4006
  }
3793
4007
  }
3794
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
+
3795
4025
  return { identifiers, wiringFile, eventBusFile };
3796
4026
  }
3797
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
+
3798
4057
  function isWorkflowTestFile(file: string): boolean {
3799
4058
  return (
3800
4059
  file.endsWith(".test.ts") ||
@@ -4115,6 +4374,15 @@ async function inspectDatabaseLifecycleDrift(
4115
4374
  schemaDir,
4116
4375
  sourceCache,
4117
4376
  )),
4377
+ ...(await inspectTenantScopeRepositoryDrift(
4378
+ targetDir,
4379
+ files,
4380
+ resources,
4381
+ config,
4382
+ infrastructurePath,
4383
+ schemaDir,
4384
+ sourceCache,
4385
+ )),
4118
4386
  );
4119
4387
  }
4120
4388
 
@@ -4274,119 +4542,702 @@ async function inspectDatabaseLifecycleDrift(
4274
4542
  return diagnostics;
4275
4543
  }
4276
4544
 
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(
4545
+ async function inspectTenantScopeRepositoryDrift(
4343
4546
  targetDir: string,
4344
4547
  files: string[],
4548
+ resources: string[],
4345
4549
  config: ResolvedBeignetConfig,
4346
4550
  infrastructurePath: string,
4347
4551
  schemaDir: string,
4348
4552
  sourceCache: Map<string, string>,
4349
4553
  ): Promise<InspectDiagnostic[]> {
4350
4554
  const diagnostics: InspectDiagnostic[] = [];
4351
- 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>();
4352
4568
 
4353
- for (const requirement of durableDrizzleTableRequirements) {
4354
- 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(
4355
4583
  targetDir,
4356
- usageFiles,
4357
- config,
4358
- requirement,
4584
+ schemaFile,
4359
4585
  sourceCache,
4360
4586
  );
4361
- if (tableNames.size === 0) continue;
4587
+ const schemaTenantColumns = tenantScopeColumnsFromSource(schemaSource);
4588
+ if (schemaTenantColumns.length === 0) continue;
4589
+ inspectedRepositories.add(drizzleRepositoryFile);
4362
4590
 
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;
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
+ });
4377
4604
  }
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
4605
  }
4386
- }
4387
-
4388
- return diagnostics;
4389
- }
4606
+
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;
5240
+ }
4390
5241
 
4391
5242
  function durableDrizzleUsageFiles(
4392
5243
  files: string[],
@@ -4761,22 +5612,70 @@ async function inspectPackageScripts(
4761
5612
  await readFile(path.join(targetDir, "package.json"), "utf8"),
4762
5613
  ) as {
4763
5614
  scripts?: Record<string, string>;
5615
+ dependencies?: Record<string, string>;
5616
+ devDependencies?: Record<string, string>;
4764
5617
  };
4765
5618
 
4766
- if (packageJson.scripts?.test) return [];
5619
+ const diagnostics: InspectDiagnostic[] = [];
5620
+ const scripts = packageJson.scripts ?? {};
5621
+ const hasDirectBunTestImports = await appUsesDirectBunTestImports(
5622
+ targetDir,
5623
+ files,
5624
+ );
4767
5625
 
4768
- return [
4769
- {
5626
+ if (!scripts.test) {
5627
+ diagnostics.push({
4770
5628
  severity: "warning",
4771
5629
  code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
4772
5630
  file: "package.json",
4773
5631
  message:
4774
- 'package.json does not define a test script. Add "test": "bun test" or run beignet doctor --fix.',
4775
- },
4776
- ];
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;
4777
5676
  }
4778
5677
 
4779
- async function fixMissingTestScript(
5678
+ async function fixPackageScripts(
4780
5679
  targetDir: string,
4781
5680
  files: string[],
4782
5681
  convention: InspectConvention,
@@ -4789,20 +5688,162 @@ async function fixMissingTestScript(
4789
5688
  const original = await readFile(filePath, "utf8");
4790
5689
  const packageJson = JSON.parse(original) as {
4791
5690
  scripts?: Record<string, string>;
5691
+ dependencies?: Record<string, string>;
5692
+ devDependencies?: Record<string, string>;
4792
5693
  };
4793
5694
  packageJson.scripts = packageJson.scripts ?? {};
4794
- 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
+ }
4795
5727
 
4796
- packageJson.scripts.test = "bun test";
4797
5728
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
4798
- if (next === original) return undefined;
5729
+ const createdSupportFiles = await createMissingTestSupportFiles(
5730
+ targetDir,
5731
+ files,
5732
+ packageJson.scripts.test === currentGeneratedPackageScripts.test,
5733
+ );
4799
5734
 
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
- };
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(" ");
4806
5847
  }
4807
5848
 
4808
5849
  function unmatchedRouteDiagnostic(
@@ -5479,14 +6520,15 @@ async function fixUnregisteredTasks(
5479
6520
  }
5480
6521
 
5481
6522
  /**
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.
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.
5485
6527
  *
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.
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.
5490
6532
  */
5491
6533
  async function fixUnregisteredListeners(
5492
6534
  targetDir: string,
@@ -5522,50 +6564,86 @@ async function fixUnregisteredListeners(
5522
6564
  if (!/\beventBus\s*[:?]/.test(portsSource)) return undefined;
5523
6565
 
5524
6566
  const providersPath = path.join(targetDir, providersFile);
5525
- const original = await readFile(providersPath, "utf8");
5526
- let next = original;
5527
- 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[] = [];
5528
6575
 
5529
6576
  for (const { registry } of candidates) {
5530
- 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);
5531
6582
  if (imported) {
5532
6583
  const importedFile = sourceFileFromImport(
5533
6584
  imported.sourcePath,
5534
- providersFile,
6585
+ listenersFile,
5535
6586
  files,
5536
6587
  );
5537
6588
  if (importedFile !== registry.indexFile) return undefined;
5538
6589
  }
5539
6590
 
5540
- const result = wireListenersProviderSource(next, {
5541
- registryName: registry.registryName,
5542
- featureKebab: listenerFeatureKebab(registry.indexFile),
5543
- listenerModule: `@/${modulePath(registry.indexFile)}`,
5544
- config,
5545
- });
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
+ });
5546
6605
  if (result.kind === "missing") return undefined;
5547
6606
  if (result.kind === "updated") {
5548
- next = result.source;
5549
- registeredNames.push(registry.registryName);
6607
+ nextListeners = result.source;
5550
6608
  }
5551
6609
  }
5552
6610
 
5553
- if (next === original || registeredNames.length === 0) return undefined;
6611
+ if (nextListeners === undefined || candidateNames.length === 0) {
6612
+ return undefined;
6613
+ }
5554
6614
 
5555
- 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
+ }
5556
6640
  return {
5557
6641
  code: "BEIGNET_LISTENER_UNREGISTERED",
5558
- file: providersFile,
5559
- 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}.`,
5560
6644
  };
5561
6645
  }
5562
6646
 
5563
- function listenerFeatureKebab(indexFile: string): string {
5564
- return path.posix.basename(
5565
- path.posix.dirname(path.posix.dirname(normalizePath(indexFile))),
5566
- );
5567
- }
5568
-
5569
6647
  async function fixUnregisteredOutboxEntries(
5570
6648
  targetDir: string,
5571
6649
  files: string[],
@@ -6270,7 +7348,9 @@ async function inspectResourceSlices(
6270
7348
  sourceIncludesAny(portSource, [
6271
7349
  `update(input: Update${singularPascal}Input)`,
6272
7350
  `update(input: Update${singularPascal}RepositoryInput)`,
7351
+ `update(input: Update${singularPascal}Input, scope: TenantScope)`,
6273
7352
  "delete(id: string): Promise<boolean>",
7353
+ "delete(id: string, scope: TenantScope): Promise<boolean>",
6274
7354
  ]);
6275
7355
 
6276
7356
  if (!hasCrudSignals) continue;
@@ -6308,6 +7388,7 @@ async function inspectResourceSlices(
6308
7388
  alternatives: [
6309
7389
  `findById(id: string): Promise<${singularPascal} | null>`,
6310
7390
  `findById(id: string, filter: ${singularPascal}TenantFilter): Promise<${singularPascal} | null>`,
7391
+ `findById(id: string, scope: TenantScope): Promise<${singularPascal} | null>`,
6311
7392
  ],
6312
7393
  },
6313
7394
  {
@@ -6315,6 +7396,7 @@ async function inspectResourceSlices(
6315
7396
  alternatives: [
6316
7397
  `update(input: Update${singularPascal}Input)`,
6317
7398
  `update(input: Update${singularPascal}RepositoryInput)`,
7399
+ `update(input: Update${singularPascal}Input, scope: TenantScope)`,
6318
7400
  ],
6319
7401
  },
6320
7402
  {
@@ -6322,6 +7404,7 @@ async function inspectResourceSlices(
6322
7404
  alternatives: [
6323
7405
  "delete(id: string): Promise<boolean>",
6324
7406
  `delete(id: string, filter: ${singularPascal}TenantFilter): Promise<boolean>`,
7407
+ "delete(id: string, scope: TenantScope): Promise<boolean>",
6325
7408
  ],
6326
7409
  },
6327
7410
  ];
@@ -6464,6 +7547,16 @@ function sourceIncludesAny(
6464
7547
  return source ? snippets.some((snippet) => source.includes(snippet)) : false;
6465
7548
  }
6466
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
+
6467
7560
  function sourceIncludesAll(
6468
7561
  source: string,
6469
7562
  snippets: readonly string[],