@beignet/cli 0.0.6 → 0.0.8
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 +16 -0
- package/README.md +40 -3
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +404 -44
- package/dist/inspect.js.map +1 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +267 -96
- 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/package.json +2 -2
- package/src/inspect.ts +648 -58
- package/src/make.ts +433 -121
- package/src/registry-edits.ts +352 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @beignet/cli
|
|
2
2
|
|
|
3
|
+
## 0.0.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 74a6859: Catch feature workflow artifacts that exist on disk but were never registered centrally. `beignet doctor` now reports unregistered feature schedules and tasks as errors, unregistered outbox events/jobs as warnings when the app has an outbox module, and feature listeners that no `registerListeners(...)` call references. `beignet doctor --fix` appends missing schedule, task, and outbox registries to their existing central files and bails out when the file is missing or customized. `beignet make event` and `beignet make job` now register new feature registries in an existing `server/outbox.ts` automatically.
|
|
8
|
+
- 74a6859: Generators now wire the provider-backed ports their output depends on. `beignet make event` and `beignet make resource --events` add `eventBus: EventBusPort` to `AppPorts`, defer the key in `infra/app-ports.ts`, register `createInMemoryEventBusProvider()` in `server/providers.ts`, and add the `@beignet/provider-event-bus-memory` dependency. `beignet make notification` wires `mailer: MailerPort` with `createMemoryMailerProvider()` and `notifications: NotificationPort` with `createInlineNotificationsProvider()` from `@beignet/core`. Each port is wired independently and skipped when the ports file already has the key, so integration-provided and app-owned wiring is left untouched, and an anchor miss aborts the generator with a manual instruction before any file is written.
|
|
9
|
+
- Updated dependencies [74a6859]
|
|
10
|
+
- Updated dependencies [74a6859]
|
|
11
|
+
- @beignet/core@0.0.8
|
|
12
|
+
|
|
13
|
+
## 0.0.7
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- @beignet/core@0.0.7
|
|
18
|
+
|
|
3
19
|
## 0.0.6
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -468,7 +468,13 @@ increment it on success. Stale updates become the generated
|
|
|
468
468
|
Use `--auth` to generate authorization metadata, policy wiring, and use-case
|
|
469
469
|
`ctx.gate.authorize(...)` calls. Use `--tenant` to scope repository reads and
|
|
470
470
|
writes by `ctx.tenant`. Use `--events` to generate created, updated, and
|
|
471
|
-
deleted domain events and publish them through `ctx.ports.eventBus`.
|
|
471
|
+
deleted domain events and publish them through `ctx.ports.eventBus`. The
|
|
472
|
+
starter ships no event bus, so `--events` also wires one: it adds
|
|
473
|
+
`eventBus: EventBusPort` to `AppPorts`, defers the key in
|
|
474
|
+
`infra/app-ports.ts`, registers `createInMemoryEventBusProvider()` in
|
|
475
|
+
`server/providers.ts`, and adds the `@beignet/provider-event-bus-memory`
|
|
476
|
+
dependency. The wiring is skipped when the ports file already mentions
|
|
477
|
+
`eventBus`, so apps that wired their own bus are left untouched. Use
|
|
472
478
|
`--soft-delete` when delete routes should archive rows with `deletedAt` while
|
|
473
479
|
list, get, and update operations continue to read only active records.
|
|
474
480
|
|
|
@@ -510,6 +516,26 @@ New task registries include `createTaskContext(...)` and
|
|
|
510
516
|
`stopTaskContext(...)` placeholders so provider startup and teardown stay
|
|
511
517
|
out of task definitions.
|
|
512
518
|
|
|
519
|
+
`make event` and `make job` also register the feature's `postEvents` and
|
|
520
|
+
`postJobs` registries in `defineOutboxRegistry({...})` when the app already
|
|
521
|
+
has a `server/outbox.ts` (or `paths.outbox`) module. The outbox registry is
|
|
522
|
+
opt-in, so the generators never create that file; `beignet doctor` reports
|
|
523
|
+
events and jobs the registry cannot deliver.
|
|
524
|
+
|
|
525
|
+
Generators wire the provider-backed ports their output depends on. `make
|
|
526
|
+
event` (and `make resource --events`) wires `eventBus: EventBusPort` with
|
|
527
|
+
`createInMemoryEventBusProvider()` from
|
|
528
|
+
`@beignet/provider-event-bus-memory`, including the package dependency.
|
|
529
|
+
`make notification` wires `mailer: MailerPort` with
|
|
530
|
+
`createMemoryMailerProvider()` and `notifications: NotificationPort` with
|
|
531
|
+
`createInlineNotificationsProvider()`, both dev-default providers from
|
|
532
|
+
`@beignet/core`. Each port is wired independently — added to `AppPorts`,
|
|
533
|
+
deferred in `infra/app-ports.ts`, and registered in `server/providers.ts` —
|
|
534
|
+
and skipped when the ports file already has the key, so integrations such as
|
|
535
|
+
resend (which contributes `mailer`) and app-owned adapters are left
|
|
536
|
+
untouched. Swap the dev-default provider for a production adapter without
|
|
537
|
+
rerunning the generator; the port stays the same.
|
|
538
|
+
|
|
513
539
|
Task, job, listener, schedule, and notification generators define artifacts
|
|
514
540
|
through app-owned context-bound builders in `lib/`. Each generator creates the
|
|
515
541
|
builder file when it is missing:
|
|
@@ -690,6 +716,11 @@ Today it detects:
|
|
|
690
716
|
|
|
691
717
|
- Contracts without a matching Next.js route handler
|
|
692
718
|
- Feature route groups that are not registered in the central route list
|
|
719
|
+
- Feature schedules and tasks that are not registered in `server/schedules.ts`
|
|
720
|
+
or `server/tasks.ts`
|
|
721
|
+
- Feature events with listeners, and feature jobs, that are missing from
|
|
722
|
+
`defineOutboxRegistry({...})` when the app has a `server/outbox.ts` module
|
|
723
|
+
- Feature listeners that no `registerListeners(...)` call ever references
|
|
693
724
|
- Next.js route handlers that do not map to known contracts
|
|
694
725
|
- OpenAPI drift in direct arrays, exported contract lists, and
|
|
695
726
|
`contractsFromRoutes(routes)` route registries
|
|
@@ -755,8 +786,14 @@ passes `doctor --strict`. Use
|
|
|
755
786
|
|
|
756
787
|
Use `beignet doctor --fix` for low-risk maintenance fixes. Today it can add
|
|
757
788
|
a missing `test` script, register existing feature route groups in the central
|
|
758
|
-
route list,
|
|
759
|
-
|
|
789
|
+
route list, register unregistered feature schedule, task, and outbox
|
|
790
|
+
event/job registries in their existing central registry files, and repair
|
|
791
|
+
direct `createOpenAPIHandler([...])` arrays when the missing contracts are
|
|
792
|
+
already imported in the OpenAPI route. Registry fixes are append-only: when a
|
|
793
|
+
central file is missing, an anchor is customized beyond recognition, or some
|
|
794
|
+
artifacts are already registered individually, the fix bails out and the
|
|
795
|
+
diagnostic stays. Listener registration is report-only because apps own their
|
|
796
|
+
event-bus wiring.
|
|
760
797
|
|
|
761
798
|
Available presets:
|
|
762
799
|
|
package/dist/inspect.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../src/inspect.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,KAAK,aAAa,EAIlB,KAAK,qBAAqB,EAE3B,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../src/inspect.ts"],"names":[],"mappings":"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"}
|
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) {
|
|
@@ -1810,6 +1819,290 @@ function isInfraOrServerFile(file, config) {
|
|
|
1810
1819
|
function containsCallExpression(source, name) {
|
|
1811
1820
|
return new RegExp(`\\b${escapeRegExp(name)}\\s*(?:<[^;=(){}]*>\\s*)?\\(`).test(source);
|
|
1812
1821
|
}
|
|
1822
|
+
const workflowRegistrySuffixes = {
|
|
1823
|
+
schedules: "Schedules",
|
|
1824
|
+
tasks: "Tasks",
|
|
1825
|
+
events: "Events",
|
|
1826
|
+
jobs: "Jobs",
|
|
1827
|
+
listeners: "Listeners",
|
|
1828
|
+
};
|
|
1829
|
+
async function inspectWorkflowRegistrationDrift(targetDir, files, config, convention) {
|
|
1830
|
+
if (!convention.resourceGenerator)
|
|
1831
|
+
return [];
|
|
1832
|
+
const drift = await workflowRegistrationDrift(targetDir, files, config);
|
|
1833
|
+
const diagnostics = [];
|
|
1834
|
+
for (const kind of ["schedules", "tasks"]) {
|
|
1835
|
+
const { centralExists, unregistered } = drift[kind];
|
|
1836
|
+
const centralFile = kind === "schedules" ? config.paths.schedules : config.paths.tasks;
|
|
1837
|
+
const code = kind === "schedules"
|
|
1838
|
+
? "BEIGNET_SCHEDULE_UNREGISTERED"
|
|
1839
|
+
: "BEIGNET_TASK_UNREGISTERED";
|
|
1840
|
+
const registrationTarget = kind === "schedules" ? "the schedules array" : "defineTasks([...])";
|
|
1841
|
+
const makeCommand = kind === "schedules" ? "beignet make schedule" : "beignet make task";
|
|
1842
|
+
const noun = kind === "schedules" ? "schedule" : "task";
|
|
1843
|
+
for (const { registry, missingMembers } of unregistered) {
|
|
1844
|
+
for (const member of missingMembers) {
|
|
1845
|
+
diagnostics.push({
|
|
1846
|
+
severity: "error",
|
|
1847
|
+
code,
|
|
1848
|
+
file: centralExists ? centralFile : registry.indexFile,
|
|
1849
|
+
message: centralExists
|
|
1850
|
+
? `${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.`
|
|
1851
|
+
: `${workflowArtifactOrigin(registry, member)}, but ${centralFile} does not exist, so the ${noun} never runs. Register ${registry.registryName} in ${centralFile} (or run \`${makeCommand}\` to create it).`,
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (drift.outbox.exists) {
|
|
1857
|
+
for (const { registry, missingMembers } of drift.outbox.events) {
|
|
1858
|
+
for (const member of missingMembers) {
|
|
1859
|
+
diagnostics.push({
|
|
1860
|
+
severity: "warning",
|
|
1861
|
+
code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
|
|
1862
|
+
file: config.paths.outbox,
|
|
1863
|
+
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.`,
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
for (const { registry, missingMembers } of drift.outbox.jobs) {
|
|
1868
|
+
for (const member of missingMembers) {
|
|
1869
|
+
diagnostics.push({
|
|
1870
|
+
severity: "warning",
|
|
1871
|
+
code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
|
|
1872
|
+
file: config.paths.outbox,
|
|
1873
|
+
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.`,
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
const infraDir = directoryPath(path.dirname(config.paths.infrastructurePorts));
|
|
1879
|
+
const listenerTarget = drift.listeners.wiringFile
|
|
1880
|
+
? `${drift.listeners.wiringFile}, which already calls registerListeners(...)`
|
|
1881
|
+
: drift.listeners.eventBusFile
|
|
1882
|
+
? `${drift.listeners.eventBusFile}, which creates the event bus`
|
|
1883
|
+
: `an infra provider under ${infraDir}/`;
|
|
1884
|
+
for (const { registry, missingMembers } of drift.listeners.unregistered) {
|
|
1885
|
+
for (const member of missingMembers) {
|
|
1886
|
+
diagnostics.push({
|
|
1887
|
+
severity: "warning",
|
|
1888
|
+
code: "BEIGNET_LISTENER_UNREGISTERED",
|
|
1889
|
+
file: drift.listeners.wiringFile ??
|
|
1890
|
+
drift.listeners.eventBusFile ??
|
|
1891
|
+
registry.indexFile,
|
|
1892
|
+
message: `${workflowArtifactOrigin(registry, member)}, but no registerListeners(...) call references it, so the listener never receives events. Register ${registry.registryName} with registerListeners(...) in ${listenerTarget}.`,
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return diagnostics;
|
|
1897
|
+
}
|
|
1898
|
+
function workflowArtifactOrigin(registry, member) {
|
|
1899
|
+
const memberFile = registry.memberFiles.get(member) ?? registry.indexFile;
|
|
1900
|
+
if (memberFile === registry.indexFile) {
|
|
1901
|
+
return `${registry.indexFile} declares ${member} in ${registry.registryName}`;
|
|
1902
|
+
}
|
|
1903
|
+
return `${memberFile} declares ${member} (exported through ${registry.registryName} in ${registry.indexFile})`;
|
|
1904
|
+
}
|
|
1905
|
+
async function workflowRegistrationDrift(targetDir, files, config) {
|
|
1906
|
+
const registries = await readFeatureWorkflowRegistries(targetDir, files, config);
|
|
1907
|
+
const byKind = (kind) => registries.filter((registry) => registry.kind === kind);
|
|
1908
|
+
const drift = {
|
|
1909
|
+
schedules: {
|
|
1910
|
+
centralExists: files.includes(config.paths.schedules),
|
|
1911
|
+
unregistered: [],
|
|
1912
|
+
},
|
|
1913
|
+
tasks: {
|
|
1914
|
+
centralExists: files.includes(config.paths.tasks),
|
|
1915
|
+
unregistered: [],
|
|
1916
|
+
},
|
|
1917
|
+
outbox: {
|
|
1918
|
+
exists: files.includes(config.paths.outbox),
|
|
1919
|
+
events: [],
|
|
1920
|
+
jobs: [],
|
|
1921
|
+
},
|
|
1922
|
+
listeners: { unregistered: [] },
|
|
1923
|
+
};
|
|
1924
|
+
if (registries.length === 0)
|
|
1925
|
+
return drift;
|
|
1926
|
+
for (const kind of ["schedules", "tasks"]) {
|
|
1927
|
+
const kindRegistries = byKind(kind);
|
|
1928
|
+
if (kindRegistries.length === 0)
|
|
1929
|
+
continue;
|
|
1930
|
+
const central = drift[kind];
|
|
1931
|
+
const registered = central.centralExists
|
|
1932
|
+
? registeredWorkflowIdentifiers(await readFile(path.join(targetDir, kind === "schedules"
|
|
1933
|
+
? config.paths.schedules
|
|
1934
|
+
: config.paths.tasks), "utf8"), kind)
|
|
1935
|
+
: new Set();
|
|
1936
|
+
central.unregistered = unregisteredWorkflowRegistries(kindRegistries, registered);
|
|
1937
|
+
}
|
|
1938
|
+
if (drift.outbox.exists) {
|
|
1939
|
+
const eventRegistries = byKind("events");
|
|
1940
|
+
const jobRegistries = byKind("jobs");
|
|
1941
|
+
if (eventRegistries.length > 0 || jobRegistries.length > 0) {
|
|
1942
|
+
const outboxSource = await readFile(path.join(targetDir, config.paths.outbox), "utf8");
|
|
1943
|
+
if (eventRegistries.length > 0) {
|
|
1944
|
+
// Events without listeners have nothing to deliver on drain, so only
|
|
1945
|
+
// record-only events stay out of the registry without a warning.
|
|
1946
|
+
const listened = await listenedEventNames(targetDir, files, config);
|
|
1947
|
+
const value = outboxRegistryValueInfo(outboxSource, "events");
|
|
1948
|
+
const registered = value
|
|
1949
|
+
? identifiersFromArrayExpression(value.text)
|
|
1950
|
+
: new Set();
|
|
1951
|
+
drift.outbox.events = unregisteredWorkflowRegistries(eventRegistries, registered, (member) => listened.has(member));
|
|
1952
|
+
}
|
|
1953
|
+
if (jobRegistries.length > 0) {
|
|
1954
|
+
const value = outboxRegistryValueInfo(outboxSource, "jobs");
|
|
1955
|
+
const registered = value
|
|
1956
|
+
? identifiersFromArrayExpression(value.text)
|
|
1957
|
+
: new Set();
|
|
1958
|
+
drift.outbox.jobs = unregisteredWorkflowRegistries(jobRegistries, registered);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
const listenerRegistries = byKind("listeners");
|
|
1963
|
+
if (listenerRegistries.length > 0) {
|
|
1964
|
+
const wiring = await listenerWiringReferences(targetDir, files);
|
|
1965
|
+
drift.listeners = {
|
|
1966
|
+
unregistered: unregisteredWorkflowRegistries(listenerRegistries, wiring.identifiers),
|
|
1967
|
+
wiringFile: wiring.wiringFile,
|
|
1968
|
+
eventBusFile: wiring.eventBusFile,
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
return drift;
|
|
1972
|
+
}
|
|
1973
|
+
function unregisteredWorkflowRegistries(registries, registered, memberApplies = () => true) {
|
|
1974
|
+
const unregistered = [];
|
|
1975
|
+
for (const registry of registries) {
|
|
1976
|
+
if (registered.has(registry.registryName))
|
|
1977
|
+
continue;
|
|
1978
|
+
const missingMembers = registry.members.filter((member) => !registered.has(member) && memberApplies(member));
|
|
1979
|
+
if (missingMembers.length === 0)
|
|
1980
|
+
continue;
|
|
1981
|
+
unregistered.push({
|
|
1982
|
+
registry,
|
|
1983
|
+
missingMembers,
|
|
1984
|
+
fullyUnregistered: registry.members.every((member) => !registered.has(member)),
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
return unregistered;
|
|
1988
|
+
}
|
|
1989
|
+
async function readFeatureWorkflowRegistries(targetDir, files, config) {
|
|
1990
|
+
const registries = [];
|
|
1991
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1992
|
+
const kinds = [
|
|
1993
|
+
"schedules",
|
|
1994
|
+
"tasks",
|
|
1995
|
+
"events",
|
|
1996
|
+
"jobs",
|
|
1997
|
+
"listeners",
|
|
1998
|
+
];
|
|
1999
|
+
for (const kind of kinds) {
|
|
2000
|
+
const folder = kind === "events" ? "domain/events" : kind;
|
|
2001
|
+
const pattern = new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/${escapeRegExp(folder)}/index\\.ts$`);
|
|
2002
|
+
for (const file of files) {
|
|
2003
|
+
if (!pattern.test(file))
|
|
2004
|
+
continue;
|
|
2005
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2006
|
+
const memberFiles = exportedMemberFiles(source, file, files);
|
|
2007
|
+
const exportRegex = new RegExp(`(?:^|\\n)export const\\s+([A-Za-z_$][\\w$]*${workflowRegistrySuffixes[kind]})\\s*=`, "g");
|
|
2008
|
+
for (const exportMatch of source.matchAll(exportRegex)) {
|
|
2009
|
+
const registryName = exportMatch[1];
|
|
2010
|
+
const info = arrayInitializerInfo(source, registryName);
|
|
2011
|
+
if (!info)
|
|
2012
|
+
continue;
|
|
2013
|
+
const members = [...identifiersFromArrayExpression(info.text)];
|
|
2014
|
+
if (members.length === 0)
|
|
2015
|
+
continue;
|
|
2016
|
+
registries.push({
|
|
2017
|
+
kind,
|
|
2018
|
+
registryName,
|
|
2019
|
+
indexFile: file,
|
|
2020
|
+
members,
|
|
2021
|
+
memberFiles,
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return registries;
|
|
2027
|
+
}
|
|
2028
|
+
function exportedMemberFiles(source, indexFile, files) {
|
|
2029
|
+
const memberFiles = new Map();
|
|
2030
|
+
const referenceRegex = /(?:import|export)\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
|
|
2031
|
+
for (const match of source.matchAll(referenceRegex)) {
|
|
2032
|
+
const resolved = sourceFileFromImport(match[2], indexFile, files);
|
|
2033
|
+
if (!resolved || !files.includes(resolved))
|
|
2034
|
+
continue;
|
|
2035
|
+
for (const member of match[1].split(",")) {
|
|
2036
|
+
const parsed = parseImportMember(member);
|
|
2037
|
+
if (parsed)
|
|
2038
|
+
memberFiles.set(parsed.localName, resolved);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return memberFiles;
|
|
2042
|
+
}
|
|
2043
|
+
function registeredWorkflowIdentifiers(source, kind) {
|
|
2044
|
+
if (kind === "tasks") {
|
|
2045
|
+
const call = /\bdefineTasks\s*(?:<[^>]*>\s*)?\(/.exec(source);
|
|
2046
|
+
if (call) {
|
|
2047
|
+
const firstArg = firstCallArgInfo(source, call.index + call[0].length);
|
|
2048
|
+
if (firstArg)
|
|
2049
|
+
return identifiersFromArrayExpression(firstArg.text);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const info = arrayInitializerInfo(source, kind === "tasks" ? "tasks" : "schedules");
|
|
2053
|
+
return info ? identifiersFromArrayExpression(info.text) : new Set();
|
|
2054
|
+
}
|
|
2055
|
+
async function listenedEventNames(targetDir, files, config) {
|
|
2056
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
2057
|
+
const listenerPattern = new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/listeners/[^/]+\\.ts$`);
|
|
2058
|
+
const listened = new Set();
|
|
2059
|
+
for (const file of files) {
|
|
2060
|
+
if (!listenerPattern.test(file) || isWorkflowTestFile(file))
|
|
2061
|
+
continue;
|
|
2062
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2063
|
+
for (const match of source.matchAll(/\bdefineListener\s*(?:<[^>]*>\s*)?\(\s*([A-Za-z_$][\w$]*)/g)) {
|
|
2064
|
+
listened.add(match[1]);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
return listened;
|
|
2068
|
+
}
|
|
2069
|
+
async function listenerWiringReferences(targetDir, files) {
|
|
2070
|
+
const identifiers = new Set();
|
|
2071
|
+
let wiringFile;
|
|
2072
|
+
let eventBusFile;
|
|
2073
|
+
for (const file of files) {
|
|
2074
|
+
if (!file.endsWith(".ts") && !file.endsWith(".tsx"))
|
|
2075
|
+
continue;
|
|
2076
|
+
if (file.endsWith(".d.ts") || isWorkflowTestFile(file))
|
|
2077
|
+
continue;
|
|
2078
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2079
|
+
let foundCall = false;
|
|
2080
|
+
for (const match of source.matchAll(/\bregisterListeners\s*\(/g)) {
|
|
2081
|
+
const openParen = (match.index ?? 0) + match[0].length - 1;
|
|
2082
|
+
const closeParen = matchingDelimiterIndex(source, openParen, "(", ")");
|
|
2083
|
+
const argsText = closeParen === -1
|
|
2084
|
+
? source.slice(openParen)
|
|
2085
|
+
: source.slice(openParen + 1, closeParen);
|
|
2086
|
+
foundCall = true;
|
|
2087
|
+
for (const identifier of identifiersFromArrayExpression(argsText)) {
|
|
2088
|
+
identifiers.add(identifier);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
if (foundCall) {
|
|
2092
|
+
wiringFile ??= file;
|
|
2093
|
+
}
|
|
2094
|
+
else if (!eventBusFile && /\bcreate\w*EventBus\s*\(/.test(source)) {
|
|
2095
|
+
eventBusFile = file;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return { identifiers, wiringFile, eventBusFile };
|
|
2099
|
+
}
|
|
2100
|
+
function isWorkflowTestFile(file) {
|
|
2101
|
+
return (file.endsWith(".test.ts") ||
|
|
2102
|
+
file.endsWith(".test.tsx") ||
|
|
2103
|
+
file.startsWith("tests/") ||
|
|
2104
|
+
file.includes("/tests/"));
|
|
2105
|
+
}
|
|
1813
2106
|
async function inspectServerlessFootguns(targetDir, files) {
|
|
1814
2107
|
const diagnostics = [];
|
|
1815
2108
|
for (const file of files) {
|
|
@@ -2612,6 +2905,115 @@ async function fixUnregisteredRouteGroups(targetDir, files, config) {
|
|
|
2612
2905
|
.join(", ")} in the central route list.`,
|
|
2613
2906
|
};
|
|
2614
2907
|
}
|
|
2908
|
+
async function fixUnregisteredSchedules(targetDir, files, drift, config) {
|
|
2909
|
+
if (!drift.schedules.centralExists)
|
|
2910
|
+
return undefined;
|
|
2911
|
+
return applyWorkflowRegistryFix({
|
|
2912
|
+
targetDir,
|
|
2913
|
+
files,
|
|
2914
|
+
centralFile: config.paths.schedules,
|
|
2915
|
+
unregistered: drift.schedules.unregistered,
|
|
2916
|
+
code: "BEIGNET_SCHEDULE_UNREGISTERED",
|
|
2917
|
+
listName: "the schedules array",
|
|
2918
|
+
importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
|
|
2919
|
+
append: (source, entry, importLine) => appendToNamedArray(source, "schedules", entry, importLine),
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
async function fixUnregisteredTasks(targetDir, files, drift, config) {
|
|
2923
|
+
if (!drift.tasks.centralExists)
|
|
2924
|
+
return undefined;
|
|
2925
|
+
return applyWorkflowRegistryFix({
|
|
2926
|
+
targetDir,
|
|
2927
|
+
files,
|
|
2928
|
+
centralFile: config.paths.tasks,
|
|
2929
|
+
unregistered: drift.tasks.unregistered,
|
|
2930
|
+
code: "BEIGNET_TASK_UNREGISTERED",
|
|
2931
|
+
listName: "defineTasks([...])",
|
|
2932
|
+
importSpecifier: (indexFile) => relativeModule(config.paths.tasks, indexFile),
|
|
2933
|
+
append: (source, entry, importLine) => appendToNamedArray(source, "tasks", entry, importLine),
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
async function fixUnregisteredOutboxEntries(targetDir, files, drift, config) {
|
|
2937
|
+
if (!drift.outbox.exists)
|
|
2938
|
+
return [];
|
|
2939
|
+
const fixes = [];
|
|
2940
|
+
const eventFix = await applyWorkflowRegistryFix({
|
|
2941
|
+
targetDir,
|
|
2942
|
+
files,
|
|
2943
|
+
centralFile: config.paths.outbox,
|
|
2944
|
+
unregistered: drift.outbox.events,
|
|
2945
|
+
code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
|
|
2946
|
+
listName: "the outbox events list",
|
|
2947
|
+
importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
|
|
2948
|
+
append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "events", entry, importLine),
|
|
2949
|
+
});
|
|
2950
|
+
if (eventFix)
|
|
2951
|
+
fixes.push(eventFix);
|
|
2952
|
+
const jobFix = await applyWorkflowRegistryFix({
|
|
2953
|
+
targetDir,
|
|
2954
|
+
files,
|
|
2955
|
+
centralFile: config.paths.outbox,
|
|
2956
|
+
unregistered: drift.outbox.jobs,
|
|
2957
|
+
code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
|
|
2958
|
+
listName: "the outbox jobs list",
|
|
2959
|
+
importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
|
|
2960
|
+
append: (source, entry, importLine) => appendToOutboxRegistryArray(source, "jobs", entry, importLine),
|
|
2961
|
+
});
|
|
2962
|
+
if (jobFix)
|
|
2963
|
+
fixes.push(jobFix);
|
|
2964
|
+
return fixes;
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Append `...<feature><Kind>` registry spreads to an existing central
|
|
2968
|
+
* registry file.
|
|
2969
|
+
*
|
|
2970
|
+
* Bails without writing when an import for the registry name resolves to a
|
|
2971
|
+
* different file, when the registry anchor is missing or unparseable, or when
|
|
2972
|
+
* some members are already individually registered (appending the registry
|
|
2973
|
+
* spread would run those members twice). The diagnostic stays in that case.
|
|
2974
|
+
*/
|
|
2975
|
+
async function applyWorkflowRegistryFix(options) {
|
|
2976
|
+
const candidates = options.unregistered.filter((entry) => entry.fullyUnregistered);
|
|
2977
|
+
if (candidates.length === 0)
|
|
2978
|
+
return undefined;
|
|
2979
|
+
const centralPath = path.join(options.targetDir, options.centralFile);
|
|
2980
|
+
let original;
|
|
2981
|
+
try {
|
|
2982
|
+
original = await readFile(centralPath, "utf8");
|
|
2983
|
+
}
|
|
2984
|
+
catch {
|
|
2985
|
+
return undefined;
|
|
2986
|
+
}
|
|
2987
|
+
let next = original;
|
|
2988
|
+
const registeredNames = [];
|
|
2989
|
+
for (const { registry } of candidates) {
|
|
2990
|
+
const imported = parseNamedImportSources(next).get(registry.registryName);
|
|
2991
|
+
let importLine;
|
|
2992
|
+
if (imported) {
|
|
2993
|
+
const importedFile = sourceFileFromImport(imported.sourcePath, options.centralFile, options.files);
|
|
2994
|
+
if (importedFile !== registry.indexFile)
|
|
2995
|
+
return undefined;
|
|
2996
|
+
}
|
|
2997
|
+
else {
|
|
2998
|
+
importLine = `import { ${registry.registryName} } from "${options.importSpecifier(registry.indexFile)}";`;
|
|
2999
|
+
}
|
|
3000
|
+
const result = options.append(next, `...${registry.registryName}`, importLine);
|
|
3001
|
+
if (result.kind === "missing")
|
|
3002
|
+
return undefined;
|
|
3003
|
+
if (result.kind === "updated") {
|
|
3004
|
+
next = result.source;
|
|
3005
|
+
registeredNames.push(registry.registryName);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
if (next === original || registeredNames.length === 0)
|
|
3009
|
+
return undefined;
|
|
3010
|
+
await writeFile(centralPath, next);
|
|
3011
|
+
return {
|
|
3012
|
+
code: options.code,
|
|
3013
|
+
file: options.centralFile,
|
|
3014
|
+
message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
2615
3017
|
function routeRegistryFileFromServerSource(source, serverFile, files) {
|
|
2616
3018
|
const imports = parseNamedImportSources(source);
|
|
2617
3019
|
for (const identifier of routeOptionIdentifiers(source)) {
|
|
@@ -2753,49 +3155,7 @@ function trimmedSlice(source, start, end) {
|
|
|
2753
3155
|
end: trimmedEnd,
|
|
2754
3156
|
};
|
|
2755
3157
|
}
|
|
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
|
-
}
|
|
3158
|
+
const contractsFromArrayExpression = identifiersFromArrayExpression;
|
|
2799
3159
|
function hasImportedIdentifier(source, identifier) {
|
|
2800
3160
|
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
|
|
2801
3161
|
for (const match of source.matchAll(importRegex)) {
|