@beignet/cli 0.0.7 → 0.0.9
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 +21 -0
- package/README.md +90 -68
- package/dist/create.js +1 -1
- package/dist/create.js.map +1 -1
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +482 -56
- package/dist/inspect.js.map +1 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +269 -98
- package/dist/make.js.map +1 -1
- package/dist/registry-edits.d.ts +77 -0
- package/dist/registry-edits.d.ts.map +1 -0
- package/dist/registry-edits.js +255 -0
- package/dist/registry-edits.js.map +1 -0
- package/dist/templates/base.js +4 -4
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/db.js +16 -16
- package/dist/templates/server.js +10 -10
- package/dist/templates/server.js.map +1 -1
- package/package.json +2 -2
- package/src/create.ts +1 -1
- package/src/inspect.ts +785 -77
- package/src/make.ts +435 -123
- package/src/registry-edits.ts +352 -0
- package/src/templates/base.ts +4 -4
- package/src/templates/db.ts +16 -16
- package/src/templates/server.ts +10 -10
package/dist/inspect.js
CHANGED
|
@@ -5,6 +5,7 @@ import { parseProviderPackageMetadata, } from "@beignet/core/providers";
|
|
|
5
5
|
import { createPainter } from "./ansi.js";
|
|
6
6
|
import { directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
|
|
7
7
|
import { formatGithubAnnotation, } from "./github-annotations.js";
|
|
8
|
+
import { appendToArrayExpression, appendToNamedArray, appendToOutboxRegistryArray, arrayInitializerInfo, identifiersFromArrayExpression, insertAfterImports, matchingDelimiterIndex, outboxRegistryValueInfo, } from "./registry-edits.js";
|
|
8
9
|
import { getCliVersion } from "./version.js";
|
|
9
10
|
export async function inspectApp(options = {}) {
|
|
10
11
|
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
@@ -52,6 +53,14 @@ export async function applyDoctorFixes(options = {}) {
|
|
|
52
53
|
const routeGroupFix = await fixUnregisteredRouteGroups(targetDir, files, config);
|
|
53
54
|
if (routeGroupFix)
|
|
54
55
|
fixes.push(routeGroupFix);
|
|
56
|
+
const drift = await workflowRegistrationDrift(targetDir, files, config);
|
|
57
|
+
const scheduleFix = await fixUnregisteredSchedules(targetDir, files, drift, config);
|
|
58
|
+
if (scheduleFix)
|
|
59
|
+
fixes.push(scheduleFix);
|
|
60
|
+
const taskFix = await fixUnregisteredTasks(targetDir, files, drift, config);
|
|
61
|
+
if (taskFix)
|
|
62
|
+
fixes.push(taskFix);
|
|
63
|
+
fixes.push(...(await fixUnregisteredOutboxEntries(targetDir, files, drift, config)));
|
|
55
64
|
const openApiFix = await fixDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes);
|
|
56
65
|
if (openApiFix)
|
|
57
66
|
fixes.push(openApiFix);
|
|
@@ -775,7 +784,7 @@ async function inspectDiagnostics(targetDir, files, config, convention, contract
|
|
|
775
784
|
for (const routeHandler of matchedRoutes.unmatchedRouteHandlers) {
|
|
776
785
|
diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
|
|
777
786
|
}
|
|
778
|
-
diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict)), ...(await inspectCanonicalConformance(targetDir, files, config, convention, strict)), ...(await inspectVersionSkew(targetDir, files)), ...(await inspectProviderMetadataDrift(targetDir, files, convention)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectProviderRegistrationDrift(targetDir, files, config, convention)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectAuthzAuditDrift(targetDir, files, config, convention, contracts)), ...(await inspectServerlessFootguns(targetDir, files)), ...(await inspectProductionReadiness(targetDir, files, config, convention)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
|
|
787
|
+
diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict)), ...(await inspectCanonicalConformance(targetDir, files, config, convention, strict)), ...(await inspectVersionSkew(targetDir, files)), ...(await inspectProviderMetadataDrift(targetDir, files, convention)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectProviderRegistrationDrift(targetDir, files, config, convention)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectAuthzAuditDrift(targetDir, files, config, convention, contracts)), ...(await inspectServerlessFootguns(targetDir, files)), ...(await inspectProductionReadiness(targetDir, files, config, convention)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectWorkflowRegistrationDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
|
|
779
788
|
return dedupeDiagnostics(diagnostics);
|
|
780
789
|
}
|
|
781
790
|
async function inspectCanonicalConformance(targetDir, files, config, convention, strict) {
|
|
@@ -1205,9 +1214,28 @@ async function inspectProductionReadiness(targetDir, files, config, convention)
|
|
|
1205
1214
|
});
|
|
1206
1215
|
}
|
|
1207
1216
|
}
|
|
1217
|
+
const providerEntries = await readProviderListEntries(targetDir, files, config, sourceCache);
|
|
1208
1218
|
for (const provider of providerDoctorRules) {
|
|
1209
1219
|
if (!installedPackages.has(provider.packageName))
|
|
1210
1220
|
continue;
|
|
1221
|
+
if (provider.variants !== undefined) {
|
|
1222
|
+
// Multi-variant packages only require the env vars of the variants the
|
|
1223
|
+
// app actually registers; an unused variant must not warn.
|
|
1224
|
+
for (const variant of detectedProviderVariants(provider.variants, providerEntries)) {
|
|
1225
|
+
for (const envVar of variant.requiredEnv) {
|
|
1226
|
+
if (await anyFileContains(targetDir, configFiles, envVar, sourceCache)) {
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
diagnostics.push({
|
|
1230
|
+
severity: "warning",
|
|
1231
|
+
code: "BEIGNET_PROVIDER_ENV_MISSING",
|
|
1232
|
+
file: "package.json",
|
|
1233
|
+
message: `${provider.packageName} is installed and ${variant.displayName} is registered, but ${envVar} is not mentioned in app env/config files. ${variant.displayName} requires ${envVar}; add the environment variable or remove the provider registration.`,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1211
1239
|
for (const envVar of provider.requiredEnv ?? []) {
|
|
1212
1240
|
if (await anyFileContains(targetDir, configFiles, envVar, sourceCache)) {
|
|
1213
1241
|
continue;
|
|
@@ -1351,17 +1379,31 @@ async function inspectProviderRegistrationDrift(targetDir, files, config, conven
|
|
|
1351
1379
|
const providerDoctorRules = await providerDoctorRulesForInstalledPackages(targetDir, packageJson);
|
|
1352
1380
|
const providerFiles = serverProviderFiles(files, config);
|
|
1353
1381
|
const sourceCache = new Map();
|
|
1354
|
-
const
|
|
1355
|
-
const providerListSource = extractProviderListSource(providerSource);
|
|
1356
|
-
const providerEntries = providerListSource === undefined
|
|
1357
|
-
? []
|
|
1358
|
-
: providerListEntries(providerListSource);
|
|
1382
|
+
const providerEntries = await readProviderListEntries(targetDir, files, config, sourceCache);
|
|
1359
1383
|
for (const provider of providerDoctorRules) {
|
|
1384
|
+
if (!installedPackages.has(provider.packageName))
|
|
1385
|
+
continue;
|
|
1386
|
+
if (provider.variants !== undefined) {
|
|
1387
|
+
if (detectedProviderVariants(provider.variants, providerEntries).length > 0) {
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
const severity = providerVariantRegistrationSeverity(provider.variants);
|
|
1391
|
+
if (severity === undefined)
|
|
1392
|
+
continue;
|
|
1393
|
+
const hints = provider.variants
|
|
1394
|
+
.filter((variant) => variant.tokens.length > 0)
|
|
1395
|
+
.map((variant) => registrationTokenHint(variant.tokens));
|
|
1396
|
+
diagnostics.push({
|
|
1397
|
+
severity,
|
|
1398
|
+
code: "BEIGNET_PROVIDER_REGISTRATION_MISSING",
|
|
1399
|
+
file: providerFiles[0] ?? config.paths.server,
|
|
1400
|
+
message: `${provider.packageName} is installed, but no ${provider.displayName} variant is registered in server/providers.ts. Register one of the available providers (${hints.join(", ")}) in the exported providers array or remove the unused package.`,
|
|
1401
|
+
});
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1360
1404
|
const severity = provider.registrationSeverity;
|
|
1361
1405
|
if (severity === undefined)
|
|
1362
1406
|
continue;
|
|
1363
|
-
if (!installedPackages.has(provider.packageName))
|
|
1364
|
-
continue;
|
|
1365
1407
|
if (provider.tokens.some((token) => providerEntries.some((entry) => entry.includes(token)))) {
|
|
1366
1408
|
continue;
|
|
1367
1409
|
}
|
|
@@ -1375,8 +1417,12 @@ async function inspectProviderRegistrationDrift(targetDir, files, config, conven
|
|
|
1375
1417
|
});
|
|
1376
1418
|
}
|
|
1377
1419
|
const drizzleIndex = findProviderEntryIndex(providerEntries, [
|
|
1378
|
-
"
|
|
1379
|
-
"
|
|
1420
|
+
"drizzleSqliteProvider",
|
|
1421
|
+
"createDrizzleSqliteProvider",
|
|
1422
|
+
"drizzlePostgresProvider",
|
|
1423
|
+
"createDrizzlePostgresProvider",
|
|
1424
|
+
"drizzleMysqlProvider",
|
|
1425
|
+
"createDrizzleMysqlProvider",
|
|
1380
1426
|
]);
|
|
1381
1427
|
const appDatabaseProviderIndex = findProviderEntryIndex(providerEntries, [
|
|
1382
1428
|
"starterDatabaseProvider",
|
|
@@ -1391,7 +1437,7 @@ async function inspectProviderRegistrationDrift(targetDir, files, config, conven
|
|
|
1391
1437
|
severity: "warning",
|
|
1392
1438
|
code: "BEIGNET_PROVIDER_ORDER",
|
|
1393
1439
|
file: providerFiles[0] ?? config.paths.server,
|
|
1394
|
-
message: "An app database provider is registered before
|
|
1440
|
+
message: "An app database provider is registered before the Drizzle database provider. Register the Drizzle database provider first so app-owned database wiring can read ctx.ports.db during setup.",
|
|
1395
1441
|
});
|
|
1396
1442
|
}
|
|
1397
1443
|
return diagnostics;
|
|
@@ -1474,10 +1520,39 @@ function providerDoctorRuleFromMetadata(packageName, metadata) {
|
|
|
1474
1520
|
tokens: registration?.tokens ?? [],
|
|
1475
1521
|
appPorts: metadata.appPorts ?? [],
|
|
1476
1522
|
requiredEnv: metadata.requiredEnv ?? [],
|
|
1477
|
-
registrationSeverity: registration
|
|
1478
|
-
|
|
1523
|
+
registrationSeverity: registrationSeverityFromMetadata(registration),
|
|
1524
|
+
variants: metadata.variants?.map((variant) => ({
|
|
1525
|
+
name: variant.name,
|
|
1526
|
+
displayName: variant.displayName ?? variant.name,
|
|
1527
|
+
tokens: variant.registration?.tokens ?? [],
|
|
1528
|
+
requiredEnv: variant.requiredEnv ?? [],
|
|
1529
|
+
registrationSeverity: registrationSeverityFromMetadata(variant.registration),
|
|
1530
|
+
})),
|
|
1479
1531
|
};
|
|
1480
1532
|
}
|
|
1533
|
+
function registrationSeverityFromMetadata(registration) {
|
|
1534
|
+
return (registration?.severity ??
|
|
1535
|
+
(registration?.required === true ? "warning" : undefined));
|
|
1536
|
+
}
|
|
1537
|
+
function detectedProviderVariants(variants, providerEntries) {
|
|
1538
|
+
return variants.filter((variant) => variant.tokens.some((token) => providerEntries.some((entry) => entry.includes(token))));
|
|
1539
|
+
}
|
|
1540
|
+
function providerVariantRegistrationSeverity(variants) {
|
|
1541
|
+
const severities = variants.map((variant) => variant.registrationSeverity);
|
|
1542
|
+
if (severities.includes("warning"))
|
|
1543
|
+
return "warning";
|
|
1544
|
+
if (severities.includes("hint"))
|
|
1545
|
+
return "hint";
|
|
1546
|
+
return undefined;
|
|
1547
|
+
}
|
|
1548
|
+
async function readProviderListEntries(targetDir, files, config, sourceCache) {
|
|
1549
|
+
const providerFiles = serverProviderFiles(files, config);
|
|
1550
|
+
const providerSource = (await Promise.all(providerFiles.map((file) => readCachedSource(targetDir, file, sourceCache)))).join("\n");
|
|
1551
|
+
const providerListSource = extractProviderListSource(providerSource);
|
|
1552
|
+
return providerListSource === undefined
|
|
1553
|
+
? []
|
|
1554
|
+
: providerListEntries(providerListSource);
|
|
1555
|
+
}
|
|
1481
1556
|
function registrationTokenHint(tokens) {
|
|
1482
1557
|
const token = tokens[0];
|
|
1483
1558
|
if (!token)
|
|
@@ -1810,6 +1885,290 @@ function isInfraOrServerFile(file, config) {
|
|
|
1810
1885
|
function containsCallExpression(source, name) {
|
|
1811
1886
|
return new RegExp(`\\b${escapeRegExp(name)}\\s*(?:<[^;=(){}]*>\\s*)?\\(`).test(source);
|
|
1812
1887
|
}
|
|
1888
|
+
const workflowRegistrySuffixes = {
|
|
1889
|
+
schedules: "Schedules",
|
|
1890
|
+
tasks: "Tasks",
|
|
1891
|
+
events: "Events",
|
|
1892
|
+
jobs: "Jobs",
|
|
1893
|
+
listeners: "Listeners",
|
|
1894
|
+
};
|
|
1895
|
+
async function inspectWorkflowRegistrationDrift(targetDir, files, config, convention) {
|
|
1896
|
+
if (!convention.resourceGenerator)
|
|
1897
|
+
return [];
|
|
1898
|
+
const drift = await workflowRegistrationDrift(targetDir, files, config);
|
|
1899
|
+
const diagnostics = [];
|
|
1900
|
+
for (const kind of ["schedules", "tasks"]) {
|
|
1901
|
+
const { centralExists, unregistered } = drift[kind];
|
|
1902
|
+
const centralFile = kind === "schedules" ? config.paths.schedules : config.paths.tasks;
|
|
1903
|
+
const code = kind === "schedules"
|
|
1904
|
+
? "BEIGNET_SCHEDULE_UNREGISTERED"
|
|
1905
|
+
: "BEIGNET_TASK_UNREGISTERED";
|
|
1906
|
+
const registrationTarget = kind === "schedules" ? "the schedules array" : "defineTasks([...])";
|
|
1907
|
+
const makeCommand = kind === "schedules" ? "beignet make schedule" : "beignet make task";
|
|
1908
|
+
const noun = kind === "schedules" ? "schedule" : "task";
|
|
1909
|
+
for (const { registry, missingMembers } of unregistered) {
|
|
1910
|
+
for (const member of missingMembers) {
|
|
1911
|
+
diagnostics.push({
|
|
1912
|
+
severity: "error",
|
|
1913
|
+
code,
|
|
1914
|
+
file: centralExists ? centralFile : registry.indexFile,
|
|
1915
|
+
message: centralExists
|
|
1916
|
+
? `${workflowArtifactOrigin(registry, member)}, but it is not registered in ${registrationTarget} in ${centralFile}, so the ${noun} never runs. Import ${registry.registryName} and spread it into the ${kind} list.`
|
|
1917
|
+
: `${workflowArtifactOrigin(registry, member)}, but ${centralFile} does not exist, so the ${noun} never runs. Register ${registry.registryName} in ${centralFile} (or run \`${makeCommand}\` to create it).`,
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (drift.outbox.exists) {
|
|
1923
|
+
for (const { registry, missingMembers } of drift.outbox.events) {
|
|
1924
|
+
for (const member of missingMembers) {
|
|
1925
|
+
diagnostics.push({
|
|
1926
|
+
severity: "warning",
|
|
1927
|
+
code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
|
|
1928
|
+
file: config.paths.outbox,
|
|
1929
|
+
message: `${workflowArtifactOrigin(registry, member)} and listeners react to it, but it is not registered in the events list of defineOutboxRegistry({...}) in ${config.paths.outbox}, so outbox drain cannot deliver it. Import ${registry.registryName} and spread it into the events list.`,
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
for (const { registry, missingMembers } of drift.outbox.jobs) {
|
|
1934
|
+
for (const member of missingMembers) {
|
|
1935
|
+
diagnostics.push({
|
|
1936
|
+
severity: "warning",
|
|
1937
|
+
code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
|
|
1938
|
+
file: config.paths.outbox,
|
|
1939
|
+
message: `${workflowArtifactOrigin(registry, member)}, but it is not registered in the jobs list of defineOutboxRegistry({...}) in ${config.paths.outbox}, so outbox drain cannot deliver it. Import ${registry.registryName} and spread it into the jobs list.`,
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
const infraDir = directoryPath(path.dirname(config.paths.infrastructurePorts));
|
|
1945
|
+
const listenerTarget = drift.listeners.wiringFile
|
|
1946
|
+
? `${drift.listeners.wiringFile}, which already calls registerListeners(...)`
|
|
1947
|
+
: drift.listeners.eventBusFile
|
|
1948
|
+
? `${drift.listeners.eventBusFile}, which creates the event bus`
|
|
1949
|
+
: `an infra provider under ${infraDir}/`;
|
|
1950
|
+
for (const { registry, missingMembers } of drift.listeners.unregistered) {
|
|
1951
|
+
for (const member of missingMembers) {
|
|
1952
|
+
diagnostics.push({
|
|
1953
|
+
severity: "warning",
|
|
1954
|
+
code: "BEIGNET_LISTENER_UNREGISTERED",
|
|
1955
|
+
file: drift.listeners.wiringFile ??
|
|
1956
|
+
drift.listeners.eventBusFile ??
|
|
1957
|
+
registry.indexFile,
|
|
1958
|
+
message: `${workflowArtifactOrigin(registry, member)}, but no registerListeners(...) call references it, so the listener never receives events. Register ${registry.registryName} with registerListeners(...) in ${listenerTarget}.`,
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
return diagnostics;
|
|
1963
|
+
}
|
|
1964
|
+
function workflowArtifactOrigin(registry, member) {
|
|
1965
|
+
const memberFile = registry.memberFiles.get(member) ?? registry.indexFile;
|
|
1966
|
+
if (memberFile === registry.indexFile) {
|
|
1967
|
+
return `${registry.indexFile} declares ${member} in ${registry.registryName}`;
|
|
1968
|
+
}
|
|
1969
|
+
return `${memberFile} declares ${member} (exported through ${registry.registryName} in ${registry.indexFile})`;
|
|
1970
|
+
}
|
|
1971
|
+
async function workflowRegistrationDrift(targetDir, files, config) {
|
|
1972
|
+
const registries = await readFeatureWorkflowRegistries(targetDir, files, config);
|
|
1973
|
+
const byKind = (kind) => registries.filter((registry) => registry.kind === kind);
|
|
1974
|
+
const drift = {
|
|
1975
|
+
schedules: {
|
|
1976
|
+
centralExists: files.includes(config.paths.schedules),
|
|
1977
|
+
unregistered: [],
|
|
1978
|
+
},
|
|
1979
|
+
tasks: {
|
|
1980
|
+
centralExists: files.includes(config.paths.tasks),
|
|
1981
|
+
unregistered: [],
|
|
1982
|
+
},
|
|
1983
|
+
outbox: {
|
|
1984
|
+
exists: files.includes(config.paths.outbox),
|
|
1985
|
+
events: [],
|
|
1986
|
+
jobs: [],
|
|
1987
|
+
},
|
|
1988
|
+
listeners: { unregistered: [] },
|
|
1989
|
+
};
|
|
1990
|
+
if (registries.length === 0)
|
|
1991
|
+
return drift;
|
|
1992
|
+
for (const kind of ["schedules", "tasks"]) {
|
|
1993
|
+
const kindRegistries = byKind(kind);
|
|
1994
|
+
if (kindRegistries.length === 0)
|
|
1995
|
+
continue;
|
|
1996
|
+
const central = drift[kind];
|
|
1997
|
+
const registered = central.centralExists
|
|
1998
|
+
? registeredWorkflowIdentifiers(await readFile(path.join(targetDir, kind === "schedules"
|
|
1999
|
+
? config.paths.schedules
|
|
2000
|
+
: config.paths.tasks), "utf8"), kind)
|
|
2001
|
+
: new Set();
|
|
2002
|
+
central.unregistered = unregisteredWorkflowRegistries(kindRegistries, registered);
|
|
2003
|
+
}
|
|
2004
|
+
if (drift.outbox.exists) {
|
|
2005
|
+
const eventRegistries = byKind("events");
|
|
2006
|
+
const jobRegistries = byKind("jobs");
|
|
2007
|
+
if (eventRegistries.length > 0 || jobRegistries.length > 0) {
|
|
2008
|
+
const outboxSource = await readFile(path.join(targetDir, config.paths.outbox), "utf8");
|
|
2009
|
+
if (eventRegistries.length > 0) {
|
|
2010
|
+
// Events without listeners have nothing to deliver on drain, so only
|
|
2011
|
+
// record-only events stay out of the registry without a warning.
|
|
2012
|
+
const listened = await listenedEventNames(targetDir, files, config);
|
|
2013
|
+
const value = outboxRegistryValueInfo(outboxSource, "events");
|
|
2014
|
+
const registered = value
|
|
2015
|
+
? identifiersFromArrayExpression(value.text)
|
|
2016
|
+
: new Set();
|
|
2017
|
+
drift.outbox.events = unregisteredWorkflowRegistries(eventRegistries, registered, (member) => listened.has(member));
|
|
2018
|
+
}
|
|
2019
|
+
if (jobRegistries.length > 0) {
|
|
2020
|
+
const value = outboxRegistryValueInfo(outboxSource, "jobs");
|
|
2021
|
+
const registered = value
|
|
2022
|
+
? identifiersFromArrayExpression(value.text)
|
|
2023
|
+
: new Set();
|
|
2024
|
+
drift.outbox.jobs = unregisteredWorkflowRegistries(jobRegistries, registered);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
const listenerRegistries = byKind("listeners");
|
|
2029
|
+
if (listenerRegistries.length > 0) {
|
|
2030
|
+
const wiring = await listenerWiringReferences(targetDir, files);
|
|
2031
|
+
drift.listeners = {
|
|
2032
|
+
unregistered: unregisteredWorkflowRegistries(listenerRegistries, wiring.identifiers),
|
|
2033
|
+
wiringFile: wiring.wiringFile,
|
|
2034
|
+
eventBusFile: wiring.eventBusFile,
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
return drift;
|
|
2038
|
+
}
|
|
2039
|
+
function unregisteredWorkflowRegistries(registries, registered, memberApplies = () => true) {
|
|
2040
|
+
const unregistered = [];
|
|
2041
|
+
for (const registry of registries) {
|
|
2042
|
+
if (registered.has(registry.registryName))
|
|
2043
|
+
continue;
|
|
2044
|
+
const missingMembers = registry.members.filter((member) => !registered.has(member) && memberApplies(member));
|
|
2045
|
+
if (missingMembers.length === 0)
|
|
2046
|
+
continue;
|
|
2047
|
+
unregistered.push({
|
|
2048
|
+
registry,
|
|
2049
|
+
missingMembers,
|
|
2050
|
+
fullyUnregistered: registry.members.every((member) => !registered.has(member)),
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
return unregistered;
|
|
2054
|
+
}
|
|
2055
|
+
async function readFeatureWorkflowRegistries(targetDir, files, config) {
|
|
2056
|
+
const registries = [];
|
|
2057
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
2058
|
+
const kinds = [
|
|
2059
|
+
"schedules",
|
|
2060
|
+
"tasks",
|
|
2061
|
+
"events",
|
|
2062
|
+
"jobs",
|
|
2063
|
+
"listeners",
|
|
2064
|
+
];
|
|
2065
|
+
for (const kind of kinds) {
|
|
2066
|
+
const folder = kind === "events" ? "domain/events" : kind;
|
|
2067
|
+
const pattern = new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/${escapeRegExp(folder)}/index\\.ts$`);
|
|
2068
|
+
for (const file of files) {
|
|
2069
|
+
if (!pattern.test(file))
|
|
2070
|
+
continue;
|
|
2071
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2072
|
+
const memberFiles = exportedMemberFiles(source, file, files);
|
|
2073
|
+
const exportRegex = new RegExp(`(?:^|\\n)export const\\s+([A-Za-z_$][\\w$]*${workflowRegistrySuffixes[kind]})\\s*=`, "g");
|
|
2074
|
+
for (const exportMatch of source.matchAll(exportRegex)) {
|
|
2075
|
+
const registryName = exportMatch[1];
|
|
2076
|
+
const info = arrayInitializerInfo(source, registryName);
|
|
2077
|
+
if (!info)
|
|
2078
|
+
continue;
|
|
2079
|
+
const members = [...identifiersFromArrayExpression(info.text)];
|
|
2080
|
+
if (members.length === 0)
|
|
2081
|
+
continue;
|
|
2082
|
+
registries.push({
|
|
2083
|
+
kind,
|
|
2084
|
+
registryName,
|
|
2085
|
+
indexFile: file,
|
|
2086
|
+
members,
|
|
2087
|
+
memberFiles,
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
return registries;
|
|
2093
|
+
}
|
|
2094
|
+
function exportedMemberFiles(source, indexFile, files) {
|
|
2095
|
+
const memberFiles = new Map();
|
|
2096
|
+
const referenceRegex = /(?:import|export)\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
|
|
2097
|
+
for (const match of source.matchAll(referenceRegex)) {
|
|
2098
|
+
const resolved = sourceFileFromImport(match[2], indexFile, files);
|
|
2099
|
+
if (!resolved || !files.includes(resolved))
|
|
2100
|
+
continue;
|
|
2101
|
+
for (const member of match[1].split(",")) {
|
|
2102
|
+
const parsed = parseImportMember(member);
|
|
2103
|
+
if (parsed)
|
|
2104
|
+
memberFiles.set(parsed.localName, resolved);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
return memberFiles;
|
|
2108
|
+
}
|
|
2109
|
+
function registeredWorkflowIdentifiers(source, kind) {
|
|
2110
|
+
if (kind === "tasks") {
|
|
2111
|
+
const call = /\bdefineTasks\s*(?:<[^>]*>\s*)?\(/.exec(source);
|
|
2112
|
+
if (call) {
|
|
2113
|
+
const firstArg = firstCallArgInfo(source, call.index + call[0].length);
|
|
2114
|
+
if (firstArg)
|
|
2115
|
+
return identifiersFromArrayExpression(firstArg.text);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
const info = arrayInitializerInfo(source, kind === "tasks" ? "tasks" : "schedules");
|
|
2119
|
+
return info ? identifiersFromArrayExpression(info.text) : new Set();
|
|
2120
|
+
}
|
|
2121
|
+
async function listenedEventNames(targetDir, files, config) {
|
|
2122
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
2123
|
+
const listenerPattern = new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/listeners/[^/]+\\.ts$`);
|
|
2124
|
+
const listened = new Set();
|
|
2125
|
+
for (const file of files) {
|
|
2126
|
+
if (!listenerPattern.test(file) || isWorkflowTestFile(file))
|
|
2127
|
+
continue;
|
|
2128
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2129
|
+
for (const match of source.matchAll(/\bdefineListener\s*(?:<[^>]*>\s*)?\(\s*([A-Za-z_$][\w$]*)/g)) {
|
|
2130
|
+
listened.add(match[1]);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return listened;
|
|
2134
|
+
}
|
|
2135
|
+
async function listenerWiringReferences(targetDir, files) {
|
|
2136
|
+
const identifiers = new Set();
|
|
2137
|
+
let wiringFile;
|
|
2138
|
+
let eventBusFile;
|
|
2139
|
+
for (const file of files) {
|
|
2140
|
+
if (!file.endsWith(".ts") && !file.endsWith(".tsx"))
|
|
2141
|
+
continue;
|
|
2142
|
+
if (file.endsWith(".d.ts") || isWorkflowTestFile(file))
|
|
2143
|
+
continue;
|
|
2144
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2145
|
+
let foundCall = false;
|
|
2146
|
+
for (const match of source.matchAll(/\bregisterListeners\s*\(/g)) {
|
|
2147
|
+
const openParen = (match.index ?? 0) + match[0].length - 1;
|
|
2148
|
+
const closeParen = matchingDelimiterIndex(source, openParen, "(", ")");
|
|
2149
|
+
const argsText = closeParen === -1
|
|
2150
|
+
? source.slice(openParen)
|
|
2151
|
+
: source.slice(openParen + 1, closeParen);
|
|
2152
|
+
foundCall = true;
|
|
2153
|
+
for (const identifier of identifiersFromArrayExpression(argsText)) {
|
|
2154
|
+
identifiers.add(identifier);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
if (foundCall) {
|
|
2158
|
+
wiringFile ??= file;
|
|
2159
|
+
}
|
|
2160
|
+
else if (!eventBusFile && /\bcreate\w*EventBus\s*\(/.test(source)) {
|
|
2161
|
+
eventBusFile = file;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
return { identifiers, wiringFile, eventBusFile };
|
|
2165
|
+
}
|
|
2166
|
+
function isWorkflowTestFile(file) {
|
|
2167
|
+
return (file.endsWith(".test.ts") ||
|
|
2168
|
+
file.endsWith(".test.tsx") ||
|
|
2169
|
+
file.startsWith("tests/") ||
|
|
2170
|
+
file.includes("/tests/"));
|
|
2171
|
+
}
|
|
1813
2172
|
async function inspectServerlessFootguns(targetDir, files) {
|
|
1814
2173
|
const diagnostics = [];
|
|
1815
2174
|
for (const file of files) {
|
|
@@ -2612,6 +2971,115 @@ async function fixUnregisteredRouteGroups(targetDir, files, config) {
|
|
|
2612
2971
|
.join(", ")} in the central route list.`,
|
|
2613
2972
|
};
|
|
2614
2973
|
}
|
|
2974
|
+
async function fixUnregisteredSchedules(targetDir, files, drift, config) {
|
|
2975
|
+
if (!drift.schedules.centralExists)
|
|
2976
|
+
return undefined;
|
|
2977
|
+
return applyWorkflowRegistryFix({
|
|
2978
|
+
targetDir,
|
|
2979
|
+
files,
|
|
2980
|
+
centralFile: config.paths.schedules,
|
|
2981
|
+
unregistered: drift.schedules.unregistered,
|
|
2982
|
+
code: "BEIGNET_SCHEDULE_UNREGISTERED",
|
|
2983
|
+
listName: "the schedules array",
|
|
2984
|
+
importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
|
|
2985
|
+
append: (source, entry, importLine) => appendToNamedArray(source, "schedules", entry, importLine),
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
async function fixUnregisteredTasks(targetDir, files, drift, config) {
|
|
2989
|
+
if (!drift.tasks.centralExists)
|
|
2990
|
+
return undefined;
|
|
2991
|
+
return applyWorkflowRegistryFix({
|
|
2992
|
+
targetDir,
|
|
2993
|
+
files,
|
|
2994
|
+
centralFile: config.paths.tasks,
|
|
2995
|
+
unregistered: drift.tasks.unregistered,
|
|
2996
|
+
code: "BEIGNET_TASK_UNREGISTERED",
|
|
2997
|
+
listName: "defineTasks([...])",
|
|
2998
|
+
importSpecifier: (indexFile) => relativeModule(config.paths.tasks, indexFile),
|
|
2999
|
+
append: (source, entry, importLine) => appendToNamedArray(source, "tasks", entry, importLine),
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
async function fixUnregisteredOutboxEntries(targetDir, files, drift, config) {
|
|
3003
|
+
if (!drift.outbox.exists)
|
|
3004
|
+
return [];
|
|
3005
|
+
const fixes = [];
|
|
3006
|
+
const eventFix = await applyWorkflowRegistryFix({
|
|
3007
|
+
targetDir,
|
|
3008
|
+
files,
|
|
3009
|
+
centralFile: config.paths.outbox,
|
|
3010
|
+
unregistered: drift.outbox.events,
|
|
3011
|
+
code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
|
|
3012
|
+
listName: "the outbox events list",
|
|
3013
|
+
importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
|
|
3014
|
+
append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "events", entry, importLine),
|
|
3015
|
+
});
|
|
3016
|
+
if (eventFix)
|
|
3017
|
+
fixes.push(eventFix);
|
|
3018
|
+
const jobFix = await applyWorkflowRegistryFix({
|
|
3019
|
+
targetDir,
|
|
3020
|
+
files,
|
|
3021
|
+
centralFile: config.paths.outbox,
|
|
3022
|
+
unregistered: drift.outbox.jobs,
|
|
3023
|
+
code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
|
|
3024
|
+
listName: "the outbox jobs list",
|
|
3025
|
+
importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
|
|
3026
|
+
append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "jobs", entry, importLine),
|
|
3027
|
+
});
|
|
3028
|
+
if (jobFix)
|
|
3029
|
+
fixes.push(jobFix);
|
|
3030
|
+
return fixes;
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Append `...<feature><Kind>` registry spreads to an existing central
|
|
3034
|
+
* registry file.
|
|
3035
|
+
*
|
|
3036
|
+
* Bails without writing when an import for the registry name resolves to a
|
|
3037
|
+
* different file, when the registry anchor is missing or unparseable, or when
|
|
3038
|
+
* some members are already individually registered (appending the registry
|
|
3039
|
+
* spread would run those members twice). The diagnostic stays in that case.
|
|
3040
|
+
*/
|
|
3041
|
+
async function applyWorkflowRegistryFix(options) {
|
|
3042
|
+
const candidates = options.unregistered.filter((entry) => entry.fullyUnregistered);
|
|
3043
|
+
if (candidates.length === 0)
|
|
3044
|
+
return undefined;
|
|
3045
|
+
const centralPath = path.join(options.targetDir, options.centralFile);
|
|
3046
|
+
let original;
|
|
3047
|
+
try {
|
|
3048
|
+
original = await readFile(centralPath, "utf8");
|
|
3049
|
+
}
|
|
3050
|
+
catch {
|
|
3051
|
+
return undefined;
|
|
3052
|
+
}
|
|
3053
|
+
let next = original;
|
|
3054
|
+
const registeredNames = [];
|
|
3055
|
+
for (const { registry } of candidates) {
|
|
3056
|
+
const imported = parseNamedImportSources(next).get(registry.registryName);
|
|
3057
|
+
let importLine;
|
|
3058
|
+
if (imported) {
|
|
3059
|
+
const importedFile = sourceFileFromImport(imported.sourcePath, options.centralFile, options.files);
|
|
3060
|
+
if (importedFile !== registry.indexFile)
|
|
3061
|
+
return undefined;
|
|
3062
|
+
}
|
|
3063
|
+
else {
|
|
3064
|
+
importLine = `import { ${registry.registryName} } from "${options.importSpecifier(registry.indexFile)}";`;
|
|
3065
|
+
}
|
|
3066
|
+
const result = options.append(next, `...${registry.registryName}`, importLine);
|
|
3067
|
+
if (result.kind === "missing")
|
|
3068
|
+
return undefined;
|
|
3069
|
+
if (result.kind === "updated") {
|
|
3070
|
+
next = result.source;
|
|
3071
|
+
registeredNames.push(registry.registryName);
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
if (next === original || registeredNames.length === 0)
|
|
3075
|
+
return undefined;
|
|
3076
|
+
await writeFile(centralPath, next);
|
|
3077
|
+
return {
|
|
3078
|
+
code: options.code,
|
|
3079
|
+
file: options.centralFile,
|
|
3080
|
+
message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
2615
3083
|
function routeRegistryFileFromServerSource(source, serverFile, files) {
|
|
2616
3084
|
const imports = parseNamedImportSources(source);
|
|
2617
3085
|
for (const identifier of routeOptionIdentifiers(source)) {
|
|
@@ -2753,49 +3221,7 @@ function trimmedSlice(source, start, end) {
|
|
|
2753
3221
|
end: trimmedEnd,
|
|
2754
3222
|
};
|
|
2755
3223
|
}
|
|
2756
|
-
|
|
2757
|
-
const withoutBrackets = expression.replace(/^\[/, "").replace(/\]$/, "");
|
|
2758
|
-
return new Set(Array.from(withoutBrackets.matchAll(/\b[A-Za-z_$][\w$]*\b/g), ([name]) => name));
|
|
2759
|
-
}
|
|
2760
|
-
function appendToArrayExpression(expression, names) {
|
|
2761
|
-
const closingBracket = /\]\s*$/.exec(expression);
|
|
2762
|
-
if (!closingBracket)
|
|
2763
|
-
return expression;
|
|
2764
|
-
const beforeClosingBracket = expression.slice(0, closingBracket.index);
|
|
2765
|
-
const closingBracketText = expression.slice(closingBracket.index);
|
|
2766
|
-
const inner = beforeClosingBracket.replace(/^\[/, "");
|
|
2767
|
-
if (!inner.trim())
|
|
2768
|
-
return `[${names.join(", ")}]`;
|
|
2769
|
-
if (!expression.includes("\n")) {
|
|
2770
|
-
const separator = /,\s*$/.test(inner) ? " " : ", ";
|
|
2771
|
-
return `${beforeClosingBracket}${separator}${names.join(", ")}${closingBracketText}`;
|
|
2772
|
-
}
|
|
2773
|
-
const itemIndent = inner
|
|
2774
|
-
.split("\n")
|
|
2775
|
-
.find((line) => line.trim())
|
|
2776
|
-
?.match(/^[\t ]*/)?.[0] ?? "\t";
|
|
2777
|
-
const closingIndent = inner.match(/\n([\t ]*)$/)?.[1] ?? "";
|
|
2778
|
-
const trimmedBeforeClosingBracket = beforeClosingBracket.replace(/\s*$/, "");
|
|
2779
|
-
const appendedNames = names.join(`,\n${itemIndent}`);
|
|
2780
|
-
if (/,\s*$/.test(inner)) {
|
|
2781
|
-
return `${trimmedBeforeClosingBracket}\n${itemIndent}${appendedNames},\n${closingIndent}${closingBracketText}`;
|
|
2782
|
-
}
|
|
2783
|
-
return `${trimmedBeforeClosingBracket},\n${itemIndent}${appendedNames}${closingBracketText}`;
|
|
2784
|
-
}
|
|
2785
|
-
function insertAfterImports(source, line) {
|
|
2786
|
-
const lines = source.split("\n");
|
|
2787
|
-
let lastImportIndex = -1;
|
|
2788
|
-
for (let index = 0; index < lines.length; index++) {
|
|
2789
|
-
if (lines[index].startsWith("import ")) {
|
|
2790
|
-
lastImportIndex = index;
|
|
2791
|
-
}
|
|
2792
|
-
}
|
|
2793
|
-
if (lastImportIndex === -1) {
|
|
2794
|
-
return `${line}\n${source}`;
|
|
2795
|
-
}
|
|
2796
|
-
lines.splice(lastImportIndex + 1, 0, line);
|
|
2797
|
-
return lines.join("\n");
|
|
2798
|
-
}
|
|
3224
|
+
const contractsFromArrayExpression = identifiersFromArrayExpression;
|
|
2799
3225
|
function hasImportedIdentifier(source, identifier) {
|
|
2800
3226
|
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
|
|
2801
3227
|
for (const match of source.matchAll(importRegex)) {
|