@beignet/cli 0.0.7 → 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/src/inspect.ts CHANGED
@@ -18,6 +18,17 @@ import {
18
18
  formatGithubAnnotation,
19
19
  type GithubAnnotationSeverity,
20
20
  } from "./github-annotations.js";
21
+ import {
22
+ type AppendResult,
23
+ appendToArrayExpression,
24
+ appendToNamedArray,
25
+ appendToOutboxRegistryArray,
26
+ arrayInitializerInfo,
27
+ identifiersFromArrayExpression,
28
+ insertAfterImports,
29
+ matchingDelimiterIndex,
30
+ outboxRegistryValueInfo,
31
+ } from "./registry-edits.js";
21
32
  import { getCliVersion } from "./version.js";
22
33
 
23
34
  type InspectAppOptions = {
@@ -225,6 +236,23 @@ export async function applyDoctorFixes(
225
236
  );
226
237
  if (routeGroupFix) fixes.push(routeGroupFix);
227
238
 
239
+ const drift = await workflowRegistrationDrift(targetDir, files, config);
240
+
241
+ const scheduleFix = await fixUnregisteredSchedules(
242
+ targetDir,
243
+ files,
244
+ drift,
245
+ config,
246
+ );
247
+ if (scheduleFix) fixes.push(scheduleFix);
248
+
249
+ const taskFix = await fixUnregisteredTasks(targetDir, files, drift, config);
250
+ if (taskFix) fixes.push(taskFix);
251
+
252
+ fixes.push(
253
+ ...(await fixUnregisteredOutboxEntries(targetDir, files, drift, config)),
254
+ );
255
+
228
256
  const openApiFix = await fixDirectOpenApiArrayDrift(
229
257
  targetDir,
230
258
  files,
@@ -1300,6 +1328,12 @@ async function inspectDiagnostics(
1300
1328
  config,
1301
1329
  convention,
1302
1330
  )),
1331
+ ...(await inspectWorkflowRegistrationDrift(
1332
+ targetDir,
1333
+ files,
1334
+ config,
1335
+ convention,
1336
+ )),
1303
1337
  ...(await inspectResourceSlices(
1304
1338
  targetDir,
1305
1339
  files,
@@ -2847,6 +2881,463 @@ function containsCallExpression(source: string, name: string): boolean {
2847
2881
  ).test(source);
2848
2882
  }
2849
2883
 
2884
+ type WorkflowRegistryKind =
2885
+ | "schedules"
2886
+ | "tasks"
2887
+ | "events"
2888
+ | "jobs"
2889
+ | "listeners";
2890
+
2891
+ type FeatureWorkflowRegistry = {
2892
+ kind: WorkflowRegistryKind;
2893
+ registryName: string;
2894
+ indexFile: string;
2895
+ members: string[];
2896
+ memberFiles: Map<string, string>;
2897
+ };
2898
+
2899
+ type UnregisteredWorkflowRegistry = {
2900
+ registry: FeatureWorkflowRegistry;
2901
+ missingMembers: string[];
2902
+ /** True when no member is individually registered either. */
2903
+ fullyUnregistered: boolean;
2904
+ };
2905
+
2906
+ type WorkflowRegistrationDrift = {
2907
+ schedules: {
2908
+ centralExists: boolean;
2909
+ unregistered: UnregisteredWorkflowRegistry[];
2910
+ };
2911
+ tasks: {
2912
+ centralExists: boolean;
2913
+ unregistered: UnregisteredWorkflowRegistry[];
2914
+ };
2915
+ outbox: {
2916
+ exists: boolean;
2917
+ events: UnregisteredWorkflowRegistry[];
2918
+ jobs: UnregisteredWorkflowRegistry[];
2919
+ };
2920
+ listeners: {
2921
+ unregistered: UnregisteredWorkflowRegistry[];
2922
+ wiringFile?: string;
2923
+ eventBusFile?: string;
2924
+ };
2925
+ };
2926
+
2927
+ const workflowRegistrySuffixes: Record<WorkflowRegistryKind, string> = {
2928
+ schedules: "Schedules",
2929
+ tasks: "Tasks",
2930
+ events: "Events",
2931
+ jobs: "Jobs",
2932
+ listeners: "Listeners",
2933
+ };
2934
+
2935
+ async function inspectWorkflowRegistrationDrift(
2936
+ targetDir: string,
2937
+ files: string[],
2938
+ config: ResolvedBeignetConfig,
2939
+ convention: InspectConvention,
2940
+ ): Promise<InspectDiagnostic[]> {
2941
+ if (!convention.resourceGenerator) return [];
2942
+
2943
+ const drift = await workflowRegistrationDrift(targetDir, files, config);
2944
+ const diagnostics: InspectDiagnostic[] = [];
2945
+
2946
+ for (const kind of ["schedules", "tasks"] as const) {
2947
+ const { centralExists, unregistered } = drift[kind];
2948
+ const centralFile =
2949
+ kind === "schedules" ? config.paths.schedules : config.paths.tasks;
2950
+ const code =
2951
+ kind === "schedules"
2952
+ ? "BEIGNET_SCHEDULE_UNREGISTERED"
2953
+ : "BEIGNET_TASK_UNREGISTERED";
2954
+ const registrationTarget =
2955
+ kind === "schedules" ? "the schedules array" : "defineTasks([...])";
2956
+ const makeCommand =
2957
+ kind === "schedules" ? "beignet make schedule" : "beignet make task";
2958
+ const noun = kind === "schedules" ? "schedule" : "task";
2959
+
2960
+ for (const { registry, missingMembers } of unregistered) {
2961
+ for (const member of missingMembers) {
2962
+ diagnostics.push({
2963
+ severity: "error",
2964
+ code,
2965
+ file: centralExists ? centralFile : registry.indexFile,
2966
+ message: centralExists
2967
+ ? `${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.`
2968
+ : `${workflowArtifactOrigin(registry, member)}, but ${centralFile} does not exist, so the ${noun} never runs. Register ${registry.registryName} in ${centralFile} (or run \`${makeCommand}\` to create it).`,
2969
+ });
2970
+ }
2971
+ }
2972
+ }
2973
+
2974
+ if (drift.outbox.exists) {
2975
+ for (const { registry, missingMembers } of drift.outbox.events) {
2976
+ for (const member of missingMembers) {
2977
+ diagnostics.push({
2978
+ severity: "warning",
2979
+ code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
2980
+ file: config.paths.outbox,
2981
+ 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.`,
2982
+ });
2983
+ }
2984
+ }
2985
+
2986
+ for (const { registry, missingMembers } of drift.outbox.jobs) {
2987
+ for (const member of missingMembers) {
2988
+ diagnostics.push({
2989
+ severity: "warning",
2990
+ code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
2991
+ file: config.paths.outbox,
2992
+ 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.`,
2993
+ });
2994
+ }
2995
+ }
2996
+ }
2997
+
2998
+ const infraDir = directoryPath(
2999
+ path.dirname(config.paths.infrastructurePorts),
3000
+ );
3001
+ const listenerTarget = drift.listeners.wiringFile
3002
+ ? `${drift.listeners.wiringFile}, which already calls registerListeners(...)`
3003
+ : drift.listeners.eventBusFile
3004
+ ? `${drift.listeners.eventBusFile}, which creates the event bus`
3005
+ : `an infra provider under ${infraDir}/`;
3006
+ for (const { registry, missingMembers } of drift.listeners.unregistered) {
3007
+ for (const member of missingMembers) {
3008
+ diagnostics.push({
3009
+ severity: "warning",
3010
+ code: "BEIGNET_LISTENER_UNREGISTERED",
3011
+ file:
3012
+ drift.listeners.wiringFile ??
3013
+ drift.listeners.eventBusFile ??
3014
+ registry.indexFile,
3015
+ message: `${workflowArtifactOrigin(registry, member)}, but no registerListeners(...) call references it, so the listener never receives events. Register ${registry.registryName} with registerListeners(...) in ${listenerTarget}.`,
3016
+ });
3017
+ }
3018
+ }
3019
+
3020
+ return diagnostics;
3021
+ }
3022
+
3023
+ function workflowArtifactOrigin(
3024
+ registry: FeatureWorkflowRegistry,
3025
+ member: string,
3026
+ ): string {
3027
+ const memberFile = registry.memberFiles.get(member) ?? registry.indexFile;
3028
+ if (memberFile === registry.indexFile) {
3029
+ return `${registry.indexFile} declares ${member} in ${registry.registryName}`;
3030
+ }
3031
+ return `${memberFile} declares ${member} (exported through ${registry.registryName} in ${registry.indexFile})`;
3032
+ }
3033
+
3034
+ async function workflowRegistrationDrift(
3035
+ targetDir: string,
3036
+ files: string[],
3037
+ config: ResolvedBeignetConfig,
3038
+ ): Promise<WorkflowRegistrationDrift> {
3039
+ const registries = await readFeatureWorkflowRegistries(
3040
+ targetDir,
3041
+ files,
3042
+ config,
3043
+ );
3044
+ const byKind = (kind: WorkflowRegistryKind) =>
3045
+ registries.filter((registry) => registry.kind === kind);
3046
+
3047
+ const drift: WorkflowRegistrationDrift = {
3048
+ schedules: {
3049
+ centralExists: files.includes(config.paths.schedules),
3050
+ unregistered: [],
3051
+ },
3052
+ tasks: {
3053
+ centralExists: files.includes(config.paths.tasks),
3054
+ unregistered: [],
3055
+ },
3056
+ outbox: {
3057
+ exists: files.includes(config.paths.outbox),
3058
+ events: [],
3059
+ jobs: [],
3060
+ },
3061
+ listeners: { unregistered: [] },
3062
+ };
3063
+ if (registries.length === 0) return drift;
3064
+
3065
+ for (const kind of ["schedules", "tasks"] as const) {
3066
+ const kindRegistries = byKind(kind);
3067
+ if (kindRegistries.length === 0) continue;
3068
+
3069
+ const central = drift[kind];
3070
+ const registered = central.centralExists
3071
+ ? registeredWorkflowIdentifiers(
3072
+ await readFile(
3073
+ path.join(
3074
+ targetDir,
3075
+ kind === "schedules"
3076
+ ? config.paths.schedules
3077
+ : config.paths.tasks,
3078
+ ),
3079
+ "utf8",
3080
+ ),
3081
+ kind,
3082
+ )
3083
+ : new Set<string>();
3084
+ central.unregistered = unregisteredWorkflowRegistries(
3085
+ kindRegistries,
3086
+ registered,
3087
+ );
3088
+ }
3089
+
3090
+ if (drift.outbox.exists) {
3091
+ const eventRegistries = byKind("events");
3092
+ const jobRegistries = byKind("jobs");
3093
+ if (eventRegistries.length > 0 || jobRegistries.length > 0) {
3094
+ const outboxSource = await readFile(
3095
+ path.join(targetDir, config.paths.outbox),
3096
+ "utf8",
3097
+ );
3098
+
3099
+ if (eventRegistries.length > 0) {
3100
+ // Events without listeners have nothing to deliver on drain, so only
3101
+ // record-only events stay out of the registry without a warning.
3102
+ const listened = await listenedEventNames(targetDir, files, config);
3103
+ const value = outboxRegistryValueInfo(outboxSource, "events");
3104
+ const registered = value
3105
+ ? identifiersFromArrayExpression(value.text)
3106
+ : new Set<string>();
3107
+ drift.outbox.events = unregisteredWorkflowRegistries(
3108
+ eventRegistries,
3109
+ registered,
3110
+ (member) => listened.has(member),
3111
+ );
3112
+ }
3113
+
3114
+ if (jobRegistries.length > 0) {
3115
+ const value = outboxRegistryValueInfo(outboxSource, "jobs");
3116
+ const registered = value
3117
+ ? identifiersFromArrayExpression(value.text)
3118
+ : new Set<string>();
3119
+ drift.outbox.jobs = unregisteredWorkflowRegistries(
3120
+ jobRegistries,
3121
+ registered,
3122
+ );
3123
+ }
3124
+ }
3125
+ }
3126
+
3127
+ const listenerRegistries = byKind("listeners");
3128
+ if (listenerRegistries.length > 0) {
3129
+ const wiring = await listenerWiringReferences(targetDir, files);
3130
+ drift.listeners = {
3131
+ unregistered: unregisteredWorkflowRegistries(
3132
+ listenerRegistries,
3133
+ wiring.identifiers,
3134
+ ),
3135
+ wiringFile: wiring.wiringFile,
3136
+ eventBusFile: wiring.eventBusFile,
3137
+ };
3138
+ }
3139
+
3140
+ return drift;
3141
+ }
3142
+
3143
+ function unregisteredWorkflowRegistries(
3144
+ registries: FeatureWorkflowRegistry[],
3145
+ registered: Set<string>,
3146
+ memberApplies: (member: string) => boolean = () => true,
3147
+ ): UnregisteredWorkflowRegistry[] {
3148
+ const unregistered: UnregisteredWorkflowRegistry[] = [];
3149
+
3150
+ for (const registry of registries) {
3151
+ if (registered.has(registry.registryName)) continue;
3152
+
3153
+ const missingMembers = registry.members.filter(
3154
+ (member) => !registered.has(member) && memberApplies(member),
3155
+ );
3156
+ if (missingMembers.length === 0) continue;
3157
+
3158
+ unregistered.push({
3159
+ registry,
3160
+ missingMembers,
3161
+ fullyUnregistered: registry.members.every(
3162
+ (member) => !registered.has(member),
3163
+ ),
3164
+ });
3165
+ }
3166
+
3167
+ return unregistered;
3168
+ }
3169
+
3170
+ async function readFeatureWorkflowRegistries(
3171
+ targetDir: string,
3172
+ files: string[],
3173
+ config: ResolvedBeignetConfig,
3174
+ ): Promise<FeatureWorkflowRegistry[]> {
3175
+ const registries: FeatureWorkflowRegistry[] = [];
3176
+ const featuresPath = directoryPath(config.paths.features);
3177
+ const kinds: WorkflowRegistryKind[] = [
3178
+ "schedules",
3179
+ "tasks",
3180
+ "events",
3181
+ "jobs",
3182
+ "listeners",
3183
+ ];
3184
+
3185
+ for (const kind of kinds) {
3186
+ const folder = kind === "events" ? "domain/events" : kind;
3187
+ const pattern = new RegExp(
3188
+ `^${escapeRegExp(featuresPath)}/[^/]+/${escapeRegExp(folder)}/index\\.ts$`,
3189
+ );
3190
+
3191
+ for (const file of files) {
3192
+ if (!pattern.test(file)) continue;
3193
+
3194
+ const source = await readFile(path.join(targetDir, file), "utf8");
3195
+ const memberFiles = exportedMemberFiles(source, file, files);
3196
+ const exportRegex = new RegExp(
3197
+ `(?:^|\\n)export const\\s+([A-Za-z_$][\\w$]*${workflowRegistrySuffixes[kind]})\\s*=`,
3198
+ "g",
3199
+ );
3200
+
3201
+ for (const exportMatch of source.matchAll(exportRegex)) {
3202
+ const registryName = exportMatch[1];
3203
+ const info = arrayInitializerInfo(source, registryName);
3204
+ if (!info) continue;
3205
+
3206
+ const members = [...identifiersFromArrayExpression(info.text)];
3207
+ if (members.length === 0) continue;
3208
+
3209
+ registries.push({
3210
+ kind,
3211
+ registryName,
3212
+ indexFile: file,
3213
+ members,
3214
+ memberFiles,
3215
+ });
3216
+ }
3217
+ }
3218
+ }
3219
+
3220
+ return registries;
3221
+ }
3222
+
3223
+ function exportedMemberFiles(
3224
+ source: string,
3225
+ indexFile: string,
3226
+ files: string[],
3227
+ ): Map<string, string> {
3228
+ const memberFiles = new Map<string, string>();
3229
+ const referenceRegex =
3230
+ /(?:import|export)\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
3231
+
3232
+ for (const match of source.matchAll(referenceRegex)) {
3233
+ const resolved = sourceFileFromImport(match[2], indexFile, files);
3234
+ if (!resolved || !files.includes(resolved)) continue;
3235
+
3236
+ for (const member of match[1].split(",")) {
3237
+ const parsed = parseImportMember(member);
3238
+ if (parsed) memberFiles.set(parsed.localName, resolved);
3239
+ }
3240
+ }
3241
+
3242
+ return memberFiles;
3243
+ }
3244
+
3245
+ function registeredWorkflowIdentifiers(
3246
+ source: string,
3247
+ kind: "schedules" | "tasks",
3248
+ ): Set<string> {
3249
+ if (kind === "tasks") {
3250
+ const call = /\bdefineTasks\s*(?:<[^>]*>\s*)?\(/.exec(source);
3251
+ if (call) {
3252
+ const firstArg = firstCallArgInfo(source, call.index + call[0].length);
3253
+ if (firstArg) return identifiersFromArrayExpression(firstArg.text);
3254
+ }
3255
+ }
3256
+
3257
+ const info = arrayInitializerInfo(
3258
+ source,
3259
+ kind === "tasks" ? "tasks" : "schedules",
3260
+ );
3261
+ return info ? identifiersFromArrayExpression(info.text) : new Set<string>();
3262
+ }
3263
+
3264
+ async function listenedEventNames(
3265
+ targetDir: string,
3266
+ files: string[],
3267
+ config: ResolvedBeignetConfig,
3268
+ ): Promise<Set<string>> {
3269
+ const featuresPath = directoryPath(config.paths.features);
3270
+ const listenerPattern = new RegExp(
3271
+ `^${escapeRegExp(featuresPath)}/[^/]+/listeners/[^/]+\\.ts$`,
3272
+ );
3273
+ const listened = new Set<string>();
3274
+
3275
+ for (const file of files) {
3276
+ if (!listenerPattern.test(file) || isWorkflowTestFile(file)) continue;
3277
+
3278
+ const source = await readFile(path.join(targetDir, file), "utf8");
3279
+ for (const match of source.matchAll(
3280
+ /\bdefineListener\s*(?:<[^>]*>\s*)?\(\s*([A-Za-z_$][\w$]*)/g,
3281
+ )) {
3282
+ listened.add(match[1]);
3283
+ }
3284
+ }
3285
+
3286
+ return listened;
3287
+ }
3288
+
3289
+ async function listenerWiringReferences(
3290
+ targetDir: string,
3291
+ files: string[],
3292
+ ): Promise<{
3293
+ identifiers: Set<string>;
3294
+ wiringFile?: string;
3295
+ eventBusFile?: string;
3296
+ }> {
3297
+ const identifiers = new Set<string>();
3298
+ let wiringFile: string | undefined;
3299
+ let eventBusFile: string | undefined;
3300
+
3301
+ for (const file of files) {
3302
+ if (!file.endsWith(".ts") && !file.endsWith(".tsx")) continue;
3303
+ if (file.endsWith(".d.ts") || isWorkflowTestFile(file)) continue;
3304
+
3305
+ const source = await readFile(path.join(targetDir, file), "utf8");
3306
+ let foundCall = false;
3307
+
3308
+ for (const match of source.matchAll(/\bregisterListeners\s*\(/g)) {
3309
+ const openParen = (match.index ?? 0) + match[0].length - 1;
3310
+ const closeParen = matchingDelimiterIndex(source, openParen, "(", ")");
3311
+ const argsText =
3312
+ closeParen === -1
3313
+ ? source.slice(openParen)
3314
+ : source.slice(openParen + 1, closeParen);
3315
+
3316
+ foundCall = true;
3317
+ for (const identifier of identifiersFromArrayExpression(argsText)) {
3318
+ identifiers.add(identifier);
3319
+ }
3320
+ }
3321
+
3322
+ if (foundCall) {
3323
+ wiringFile ??= file;
3324
+ } else if (!eventBusFile && /\bcreate\w*EventBus\s*\(/.test(source)) {
3325
+ eventBusFile = file;
3326
+ }
3327
+ }
3328
+
3329
+ return { identifiers, wiringFile, eventBusFile };
3330
+ }
3331
+
3332
+ function isWorkflowTestFile(file: string): boolean {
3333
+ return (
3334
+ file.endsWith(".test.ts") ||
3335
+ file.endsWith(".test.tsx") ||
3336
+ file.startsWith("tests/") ||
3337
+ file.includes("/tests/")
3338
+ );
3339
+ }
3340
+
2850
3341
  async function inspectServerlessFootguns(
2851
3342
  targetDir: string,
2852
3343
  files: string[],
@@ -4057,6 +4548,162 @@ async function fixUnregisteredRouteGroups(
4057
4548
  };
4058
4549
  }
4059
4550
 
4551
+ async function fixUnregisteredSchedules(
4552
+ targetDir: string,
4553
+ files: string[],
4554
+ drift: WorkflowRegistrationDrift,
4555
+ config: ResolvedBeignetConfig,
4556
+ ): Promise<InspectFix | undefined> {
4557
+ if (!drift.schedules.centralExists) return undefined;
4558
+
4559
+ return applyWorkflowRegistryFix({
4560
+ targetDir,
4561
+ files,
4562
+ centralFile: config.paths.schedules,
4563
+ unregistered: drift.schedules.unregistered,
4564
+ code: "BEIGNET_SCHEDULE_UNREGISTERED",
4565
+ listName: "the schedules array",
4566
+ importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4567
+ append: (source, entry, importLine) =>
4568
+ appendToNamedArray(source, "schedules", entry, importLine),
4569
+ });
4570
+ }
4571
+
4572
+ async function fixUnregisteredTasks(
4573
+ targetDir: string,
4574
+ files: string[],
4575
+ drift: WorkflowRegistrationDrift,
4576
+ config: ResolvedBeignetConfig,
4577
+ ): Promise<InspectFix | undefined> {
4578
+ if (!drift.tasks.centralExists) return undefined;
4579
+
4580
+ return applyWorkflowRegistryFix({
4581
+ targetDir,
4582
+ files,
4583
+ centralFile: config.paths.tasks,
4584
+ unregistered: drift.tasks.unregistered,
4585
+ code: "BEIGNET_TASK_UNREGISTERED",
4586
+ listName: "defineTasks([...])",
4587
+ importSpecifier: (indexFile) =>
4588
+ relativeModule(config.paths.tasks, indexFile),
4589
+ append: (source, entry, importLine) =>
4590
+ appendToNamedArray(source, "tasks", entry, importLine),
4591
+ });
4592
+ }
4593
+
4594
+ async function fixUnregisteredOutboxEntries(
4595
+ targetDir: string,
4596
+ files: string[],
4597
+ drift: WorkflowRegistrationDrift,
4598
+ config: ResolvedBeignetConfig,
4599
+ ): Promise<InspectFix[]> {
4600
+ if (!drift.outbox.exists) return [];
4601
+
4602
+ const fixes: InspectFix[] = [];
4603
+
4604
+ const eventFix = await applyWorkflowRegistryFix({
4605
+ targetDir,
4606
+ files,
4607
+ centralFile: config.paths.outbox,
4608
+ unregistered: drift.outbox.events,
4609
+ code: "BEIGNET_OUTBOX_EVENT_UNREGISTERED",
4610
+ listName: "the outbox events list",
4611
+ importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4612
+ append: (source, entry, importLine) =>
4613
+ appendToOutboxRegistryArray(source, "events", entry, importLine),
4614
+ });
4615
+ if (eventFix) fixes.push(eventFix);
4616
+
4617
+ const jobFix = await applyWorkflowRegistryFix({
4618
+ targetDir,
4619
+ files,
4620
+ centralFile: config.paths.outbox,
4621
+ unregistered: drift.outbox.jobs,
4622
+ code: "BEIGNET_OUTBOX_JOB_UNREGISTERED",
4623
+ listName: "the outbox jobs list",
4624
+ importSpecifier: (indexFile) => `@/${modulePath(indexFile)}`,
4625
+ append: (source, entry, importLine) =>
4626
+ appendToOutboxRegistryArray(source, "jobs", entry, importLine),
4627
+ });
4628
+ if (jobFix) fixes.push(jobFix);
4629
+
4630
+ return fixes;
4631
+ }
4632
+
4633
+ /**
4634
+ * Append `...<feature><Kind>` registry spreads to an existing central
4635
+ * registry file.
4636
+ *
4637
+ * Bails without writing when an import for the registry name resolves to a
4638
+ * different file, when the registry anchor is missing or unparseable, or when
4639
+ * some members are already individually registered (appending the registry
4640
+ * spread would run those members twice). The diagnostic stays in that case.
4641
+ */
4642
+ async function applyWorkflowRegistryFix(options: {
4643
+ targetDir: string;
4644
+ files: string[];
4645
+ centralFile: string;
4646
+ unregistered: UnregisteredWorkflowRegistry[];
4647
+ code: string;
4648
+ listName: string;
4649
+ importSpecifier: (indexFile: string) => string;
4650
+ append: (source: string, entry: string, importLine?: string) => AppendResult;
4651
+ }): Promise<InspectFix | undefined> {
4652
+ const candidates = options.unregistered.filter(
4653
+ (entry) => entry.fullyUnregistered,
4654
+ );
4655
+ if (candidates.length === 0) return undefined;
4656
+
4657
+ const centralPath = path.join(options.targetDir, options.centralFile);
4658
+ let original: string;
4659
+ try {
4660
+ original = await readFile(centralPath, "utf8");
4661
+ } catch {
4662
+ return undefined;
4663
+ }
4664
+
4665
+ let next = original;
4666
+ const registeredNames: string[] = [];
4667
+
4668
+ for (const { registry } of candidates) {
4669
+ const imported = parseNamedImportSources(next).get(registry.registryName);
4670
+ let importLine: string | undefined;
4671
+
4672
+ if (imported) {
4673
+ const importedFile = sourceFileFromImport(
4674
+ imported.sourcePath,
4675
+ options.centralFile,
4676
+ options.files,
4677
+ );
4678
+ if (importedFile !== registry.indexFile) return undefined;
4679
+ } else {
4680
+ importLine = `import { ${registry.registryName} } from "${options.importSpecifier(
4681
+ registry.indexFile,
4682
+ )}";`;
4683
+ }
4684
+
4685
+ const result = options.append(
4686
+ next,
4687
+ `...${registry.registryName}`,
4688
+ importLine,
4689
+ );
4690
+ if (result.kind === "missing") return undefined;
4691
+ if (result.kind === "updated") {
4692
+ next = result.source;
4693
+ registeredNames.push(registry.registryName);
4694
+ }
4695
+ }
4696
+
4697
+ if (next === original || registeredNames.length === 0) return undefined;
4698
+
4699
+ await writeFile(centralPath, next);
4700
+ return {
4701
+ code: options.code,
4702
+ file: options.centralFile,
4703
+ message: `Registered ${registeredNames.join(", ")} in ${options.listName}.`,
4704
+ };
4705
+ }
4706
+
4060
4707
  function routeRegistryFileFromServerSource(
4061
4708
  source: string,
4062
4709
  serverFile: string,
@@ -4315,64 +4962,7 @@ function trimmedSlice(
4315
4962
  };
4316
4963
  }
4317
4964
 
4318
- function contractsFromArrayExpression(expression: string): Set<string> {
4319
- const withoutBrackets = expression.replace(/^\[/, "").replace(/\]$/, "");
4320
-
4321
- return new Set(
4322
- Array.from(
4323
- withoutBrackets.matchAll(/\b[A-Za-z_$][\w$]*\b/g),
4324
- ([name]) => name,
4325
- ),
4326
- );
4327
- }
4328
-
4329
- function appendToArrayExpression(expression: string, names: string[]): string {
4330
- const closingBracket = /\]\s*$/.exec(expression);
4331
- if (!closingBracket) return expression;
4332
-
4333
- const beforeClosingBracket = expression.slice(0, closingBracket.index);
4334
- const closingBracketText = expression.slice(closingBracket.index);
4335
- const inner = beforeClosingBracket.replace(/^\[/, "");
4336
- if (!inner.trim()) return `[${names.join(", ")}]`;
4337
-
4338
- if (!expression.includes("\n")) {
4339
- const separator = /,\s*$/.test(inner) ? " " : ", ";
4340
- return `${beforeClosingBracket}${separator}${names.join(", ")}${closingBracketText}`;
4341
- }
4342
-
4343
- const itemIndent =
4344
- inner
4345
- .split("\n")
4346
- .find((line) => line.trim())
4347
- ?.match(/^[\t ]*/)?.[0] ?? "\t";
4348
- const closingIndent = inner.match(/\n([\t ]*)$/)?.[1] ?? "";
4349
- const trimmedBeforeClosingBracket = beforeClosingBracket.replace(/\s*$/, "");
4350
- const appendedNames = names.join(`,\n${itemIndent}`);
4351
-
4352
- if (/,\s*$/.test(inner)) {
4353
- return `${trimmedBeforeClosingBracket}\n${itemIndent}${appendedNames},\n${closingIndent}${closingBracketText}`;
4354
- }
4355
-
4356
- return `${trimmedBeforeClosingBracket},\n${itemIndent}${appendedNames}${closingBracketText}`;
4357
- }
4358
-
4359
- function insertAfterImports(source: string, line: string): string {
4360
- const lines = source.split("\n");
4361
- let lastImportIndex = -1;
4362
-
4363
- for (let index = 0; index < lines.length; index++) {
4364
- if (lines[index].startsWith("import ")) {
4365
- lastImportIndex = index;
4366
- }
4367
- }
4368
-
4369
- if (lastImportIndex === -1) {
4370
- return `${line}\n${source}`;
4371
- }
4372
-
4373
- lines.splice(lastImportIndex + 1, 0, line);
4374
- return lines.join("\n");
4375
- }
4965
+ const contractsFromArrayExpression = identifiersFromArrayExpression;
4376
4966
 
4377
4967
  function hasImportedIdentifier(source: string, identifier: string): boolean {
4378
4968
  const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;