@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.
- package/CHANGELOG.md +49 -0
- package/README.md +26 -16
- 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 +797 -58
- package/dist/inspect.js.map +1 -1
- package/dist/make/inbox.d.ts.map +1 -1
- package/dist/make/inbox.js +5 -3
- package/dist/make/inbox.js.map +1 -1
- package/dist/make/payments.d.ts.map +1 -1
- package/dist/make/payments.js +58 -29
- 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 +44 -10
- package/dist/make/shared.js.map +1 -1
- package/dist/make/tenancy.d.ts.map +1 -1
- package/dist/make/tenancy.js +6 -4
- package/dist/make/tenancy.js.map +1 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +129 -55
- 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 +46 -0
- package/dist/provider-add.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 +3 -3
- 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 +6 -1
- 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 +2 -2
- package/package.json +3 -2
- package/skills/app-structure/SKILL.md +12 -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 +1247 -154
- package/src/make/inbox.ts +5 -3
- package/src/make/payments.ts +58 -29
- package/src/make/shared.ts +78 -11
- package/src/make/tenancy.ts +6 -4
- package/src/make.ts +172 -54
- package/src/outbox.ts +363 -0
- package/src/preflight.ts +1 -1
- package/src/provider-add.ts +47 -0
- package/src/task.ts +2 -3
- package/src/templates/agents.ts +6 -3
- package/src/templates/base.ts +3 -3
- package/src/templates/index.ts +2 -0
- package/src/templates/server.ts +6 -1
- package/src/templates/shared.ts +13 -1
- package/src/templates/testing.ts +545 -0
- package/src/templates/todos.ts +2 -2
- 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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4354
|
-
const
|
|
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
|
-
|
|
4357
|
-
config,
|
|
4358
|
-
requirement,
|
|
4584
|
+
schemaFile,
|
|
4359
4585
|
sourceCache,
|
|
4360
4586
|
);
|
|
4361
|
-
|
|
4587
|
+
const schemaTenantColumns = tenantScopeColumnsFromSource(schemaSource);
|
|
4588
|
+
if (schemaTenantColumns.length === 0) continue;
|
|
4589
|
+
inspectedRepositories.add(drizzleRepositoryFile);
|
|
4362
4590
|
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5619
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
5620
|
+
const scripts = packageJson.scripts ?? {};
|
|
5621
|
+
const hasDirectBunTestImports = await appUsesDirectBunTestImports(
|
|
5622
|
+
targetDir,
|
|
5623
|
+
files,
|
|
5624
|
+
);
|
|
4767
5625
|
|
|
4768
|
-
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5729
|
+
const createdSupportFiles = await createMissingTestSupportFiles(
|
|
5730
|
+
targetDir,
|
|
5731
|
+
files,
|
|
5732
|
+
packageJson.scripts.test === currentGeneratedPackageScripts.test,
|
|
5733
|
+
);
|
|
4799
5734
|
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
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
|
|
5483
|
-
*
|
|
5484
|
-
* generates, so both
|
|
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
|
|
5487
|
-
*
|
|
5488
|
-
* against, or when the registry name is already imported from another
|
|
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
|
|
5526
|
-
|
|
5527
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
6585
|
+
listenersFile,
|
|
5535
6586
|
files,
|
|
5536
6587
|
);
|
|
5537
6588
|
if (importedFile !== registry.indexFile) return undefined;
|
|
5538
6589
|
}
|
|
5539
6590
|
|
|
5540
|
-
const
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
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
|
-
|
|
5549
|
-
registeredNames.push(registry.registryName);
|
|
6607
|
+
nextListeners = result.source;
|
|
5550
6608
|
}
|
|
5551
6609
|
}
|
|
5552
6610
|
|
|
5553
|
-
if (
|
|
6611
|
+
if (nextListeners === undefined || candidateNames.length === 0) {
|
|
6612
|
+
return undefined;
|
|
6613
|
+
}
|
|
5554
6614
|
|
|
5555
|
-
|
|
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:
|
|
5559
|
-
message: `Registered ${
|
|
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[],
|