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