@cosmicdrift/kumiko-framework 0.234.0 → 0.235.1

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.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/unprocessable-error-details-reason-codemod.test.ts +205 -0
  3. package/src/api/server.ts +3 -0
  4. package/src/changes.json +8 -0
  5. package/src/db/__tests__/list-row-meta-drift.test.ts +15 -0
  6. package/src/db/queries/event-consumer.ts +20 -5
  7. package/src/engine/__tests__/boot-validator.test.ts +114 -0
  8. package/src/engine/__tests__/build-app-schema.test.ts +48 -1
  9. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +140 -0
  10. package/src/engine/boot-validator/entity-list-screens.ts +13 -1
  11. package/src/engine/boot-validator/i18n-keys.ts +13 -5
  12. package/src/engine/boot-validator/screens.ts +25 -1
  13. package/src/engine/build-app-schema.ts +14 -0
  14. package/src/engine/build-config-feature-schema.ts +216 -44
  15. package/src/engine/index.ts +2 -0
  16. package/src/engine/registry-validate.ts +16 -0
  17. package/src/engine/registry.ts +2 -0
  18. package/src/engine/types/index.ts +2 -0
  19. package/src/i18n/index.ts +1 -0
  20. package/src/i18n/required-surface-keys.ts +11 -0
  21. package/src/i18n/settings-hub-keys.ts +28 -0
  22. package/src/jobs/__tests__/jobs.integration.test.ts +127 -0
  23. package/src/jobs/job-runner.ts +37 -2
  24. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +176 -0
  25. package/src/pipeline/event-dispatcher-delivery.ts +1 -1
  26. package/src/pipeline/event-dispatcher.ts +6 -0
  27. package/src/pipeline/multi-stream-apply-context.ts +4 -4
  28. package/src/scripts/codemod/unprocessable-error-details-reason.ts +273 -0
  29. package/src/testing/e2e-generator.ts +3 -0
  30. package/src/ui-types/index.ts +4 -0
  31. package/src/ui-types/list-row-meta.ts +25 -0
@@ -15,6 +15,7 @@ import { createInMemoryFileProvider } from "../../files/in-memory-provider";
15
15
  import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
16
16
  import { sleep, waitFor } from "../../testing";
17
17
  import {
18
+ bootJobIdForJobName,
18
19
  createJobRunner,
19
20
  type JobLogEntry,
20
21
  type JobMeta,
@@ -1222,3 +1223,129 @@ describe("job registry", () => {
1222
1223
  expect(job?.runOnBoot).toBe(true);
1223
1224
  });
1224
1225
  });
1226
+
1227
+ // --- Boot gates: only an inline gate can abort a boot (fw#2590) ---
1228
+
1229
+ describe("boot gates", () => {
1230
+ const gateLog: string[] = [];
1231
+ const GATE_FAILURE = "required legal blocks are not seeded";
1232
+
1233
+ function uniquePrefix(): string {
1234
+ return `kumiko-gate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1235
+ }
1236
+
1237
+ function createGateRunner(
1238
+ feature: ReturnType<typeof defineFeature>,
1239
+ queueNamePrefix: string,
1240
+ ): JobRunner {
1241
+ const context: AppContext = {};
1242
+ return createJobRunner({
1243
+ registry: createRegistry([feature]),
1244
+ context,
1245
+ redisUrl,
1246
+ consumerLane: "worker",
1247
+ queueNamePrefix,
1248
+ });
1249
+ }
1250
+
1251
+ async function purge(queueNamePrefix: string): Promise<void> {
1252
+ const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
1253
+ if (keys.length > 0) await testRedis.redis.del(...keys);
1254
+ }
1255
+
1256
+ test("a throwing gate rejects start() and stops the remaining boot wiring", async () => {
1257
+ const feature = defineFeature("failgate", (r) => {
1258
+ r.job("check", { trigger: { manual: true }, bootGate: true }, async () => {
1259
+ throw new Error(GATE_FAILURE);
1260
+ });
1261
+ r.job("afterGate", { trigger: { manual: true }, runOnBoot: true }, async () => {
1262
+ gateLog.push("after-gate");
1263
+ });
1264
+ });
1265
+ const prefix = uniquePrefix();
1266
+ const runner = createGateRunner(feature, prefix);
1267
+ gateLog.length = 0;
1268
+
1269
+ // Pin the qualified name the boot-job-id assertion below is built on —
1270
+ // a typo there would make that assertion pass vacuously.
1271
+ const afterGateName = "failgate:job:after-gate";
1272
+ expect(createRegistry([feature]).getJob(afterGateName)).toBeDefined();
1273
+
1274
+ const queue = new Queue(`${prefix}-worker`, {
1275
+ connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
1276
+ });
1277
+ // A post-close 'error' here is otherwise unhandled and bun:test
1278
+ // attributes it to whichever test runs next (fw#1805).
1279
+ queue.on("error", () => {});
1280
+
1281
+ try {
1282
+ // The gate's own message must survive — which blocks are missing is the
1283
+ // entire operational value, and onJobFailed runs before the re-throw.
1284
+ await expect(runner.start()).rejects.toThrow(GATE_FAILURE);
1285
+ await sleep(300);
1286
+ // The gate aborted start() before any further boot wiring ran: the
1287
+ // runOnBoot job was never even enqueued, and never executed.
1288
+ const bootJob = await queue.getJob(bootJobIdForJobName(afterGateName));
1289
+ expect(bootJob).toBeUndefined();
1290
+ expect(gateLog).toEqual([]);
1291
+ } finally {
1292
+ await queue.close();
1293
+ await runner.stop();
1294
+ await purge(prefix);
1295
+ }
1296
+ });
1297
+
1298
+ test("a gate runs on every start, unlike a deduped runOnBoot job", async () => {
1299
+ const feature = defineFeature("everygate", (r) => {
1300
+ r.job("check", { trigger: { manual: true }, bootGate: true }, async () => {
1301
+ gateLog.push("gate");
1302
+ });
1303
+ r.job("once", { trigger: { manual: true }, runOnBoot: true }, async () => {
1304
+ gateLog.push("boot");
1305
+ });
1306
+ });
1307
+ const prefix = uniquePrefix();
1308
+ gateLog.length = 0;
1309
+
1310
+ const first = createGateRunner(feature, prefix);
1311
+ await first.start();
1312
+ await waitFor(() => {
1313
+ expect(gateLog.filter((e) => e === "boot").length).toBe(1);
1314
+ });
1315
+ await first.stop();
1316
+
1317
+ const second = createGateRunner(feature, prefix);
1318
+ try {
1319
+ await second.start();
1320
+ await sleep(300);
1321
+ expect(gateLog.filter((e) => e === "gate").length).toBe(2);
1322
+ // Same fixed boot job id, still present in Redis from the first start,
1323
+ // so BullMQ drops the second enqueue — that dedup is why runOnBoot
1324
+ // cannot gate a deploy.
1325
+ expect(gateLog.filter((e) => e === "boot").length).toBe(1);
1326
+ } finally {
1327
+ await second.stop();
1328
+ await purge(prefix);
1329
+ }
1330
+ });
1331
+
1332
+ test("bootGate is rejected for perTenant and sequential jobs", () => {
1333
+ const perTenantGate = defineFeature("pergate", (r) => {
1334
+ r.job("check", { trigger: { manual: true }, bootGate: true, perTenant: true }, async () => {
1335
+ gateLog.push("never");
1336
+ });
1337
+ });
1338
+ expect(() => createRegistry([perTenantGate])).toThrow(/bootGate with perTenant/);
1339
+
1340
+ const sequentialGate = defineFeature("seqgate", (r) => {
1341
+ r.job(
1342
+ "check",
1343
+ { trigger: { manual: true }, bootGate: true, concurrency: "sequential" },
1344
+ async () => {
1345
+ gateLog.push("never");
1346
+ },
1347
+ );
1348
+ });
1349
+ expect(() => createRegistry([sequentialGate])).toThrow(/bootGate with concurrency/);
1350
+ });
1351
+ });
@@ -1,4 +1,4 @@
1
- import { type Job, Queue, Worker } from "bullmq";
1
+ import { Queue, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
@@ -395,7 +395,17 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
395
395
  return false;
396
396
  }
397
397
 
398
- async function handleJob(bullJob: Job): Promise<void> {
398
+ // Structural instead of BullMQ's `Job` so boot gates can invoke the same
399
+ // execution path inline. A real `Job` stays assignable, so handleJob is
400
+ // still a valid BullMQ Processor.
401
+ type JobInvocation = {
402
+ readonly id?: string | undefined;
403
+ readonly name: string;
404
+ readonly data: Record<string, unknown>;
405
+ readonly attemptsMade: number;
406
+ };
407
+
408
+ async function handleJob(bullJob: JobInvocation): Promise<void> {
399
409
  const rawName = bullJob.name;
400
410
 
401
411
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
@@ -651,6 +661,27 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
651
661
  }
652
662
  }
653
663
 
664
+ // Boot gates run inline and are awaited, so a throwing gate rejects start()
665
+ // — and with it the entrypoint's start() and runProdApp's boot, before the
666
+ // process reports ready. Enqueuing them (as runOnBoot does) would only fail
667
+ // a queue job and let the deploy through (fw#2590).
668
+ async function runBootGates(lane: JobRunIn): Promise<void> {
669
+ for (const [name, jobDef] of allJobs) {
670
+ if (laneForJob(jobDef) !== lane) continue;
671
+ if (!jobDef.bootGate) continue;
672
+ try {
673
+ await handleJob({ id: bootJobIdForJobName(name), name, data: {}, attemptsMade: 0 });
674
+ } catch (err) {
675
+ // The worker is already consuming here; leaving its Redis connections
676
+ // open would keep the event loop alive and turn the aborted boot into
677
+ // a hanging process instead of a non-zero exit.
678
+ await worker?.close();
679
+ worker = null;
680
+ throw err;
681
+ }
682
+ }
683
+ }
684
+
654
685
  const runnerApi: JobRunner = {
655
686
  async start(): Promise<void> {
656
687
  // skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
@@ -689,6 +720,10 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
689
720
  bootTimeout.cancel();
690
721
  }
691
722
 
723
+ // Ahead of cron/runOnBoot wiring on purpose: a failed gate must not
724
+ // leave recurring schedulers behind.
725
+ await runBootGates(consumerLane);
726
+
692
727
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
693
728
  // assigned to the other lane get their cron/boot wiring from the
694
729
  // runner running on that lane. Running both here would double-fire.
@@ -0,0 +1,176 @@
1
+ // EventConsumer.startFrom — opt-in cursor seed on FIRST registration
2
+ // (fw#2482/1). Mounting a NEW consumer into an app whose event log is
3
+ // already populated must not replay history and fire every side-effect
4
+ // step retroactively.
5
+ //
6
+ // setupTestStack's own dispatcher pre-registers every consumer eagerly at
7
+ // stack construction time (test-stack.ts: "Pre-register consumer state
8
+ // rows..."), before any test body can seed historical events — so it can't
9
+ // reproduce "mount into an already-running app" the way this fix needs.
10
+ // This test instead builds its own EventDispatcher via createEventDispatcher()
11
+ // against a plain createTestDb(), and only calls ensureRegistered() AFTER
12
+ // historical events already exist — the same order a real deploy of a new
13
+ // event-triggered workflow hits in production.
14
+
15
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
+ import type { DbTx } from "../../db/connection";
17
+ import { insertOne } from "../../db/query";
18
+ import type { AppContext } from "../../engine/types";
19
+ import { eventsTable } from "../../event-store";
20
+ import { createTestDb, type TestDb, TestUsers } from "../../stack";
21
+ import { createEventConsumerStateTable } from "../event-consumer-state";
22
+ import { createEventDispatcher, type EventConsumer, getConsumerState } from "../event-dispatcher";
23
+ import { fetchPendingEvents } from "../event-dispatcher-delivery";
24
+
25
+ const admin = TestUsers.admin;
26
+ let testDb: TestDb;
27
+ let historicalEventCount = 0;
28
+
29
+ function stubContext(): AppContext {
30
+ return {
31
+ db: {} as unknown as AppContext["db"],
32
+ redis: {} as unknown as AppContext["redis"],
33
+ registry: {} as unknown as AppContext["registry"],
34
+ } as AppContext;
35
+ }
36
+
37
+ async function appendHistoricalEvent(type: string): Promise<void> {
38
+ await insertOne(testDb.db, eventsTable, {
39
+ aggregateId: crypto.randomUUID(),
40
+ aggregateType: "start-from-test-source",
41
+ tenantId: admin.tenantId,
42
+ version: 1,
43
+ type,
44
+ eventVersion: 1,
45
+ payload: {},
46
+ metadata: { userId: admin.id },
47
+ createdBy: admin.id,
48
+ });
49
+ historicalEventCount += 1;
50
+ }
51
+
52
+ beforeAll(async () => {
53
+ // createTestDb() already materializes kumiko_events (see stack/db.ts).
54
+ testDb = await createTestDb();
55
+ await createEventConsumerStateTable(testDb.db);
56
+ });
57
+
58
+ afterAll(async () => {
59
+ await testDb.cleanup();
60
+ });
61
+
62
+ describe("EventConsumer.startFrom", () => {
63
+ test("'now' seeds the cursor at MAX(events.id) on first registration and skips the historical log", async () => {
64
+ await appendHistoricalEvent("start-from-test.one");
65
+ await appendHistoricalEvent("start-from-test.two");
66
+ await appendHistoricalEvent("start-from-test.three");
67
+ const headAtMount = BigInt(historicalEventCount);
68
+
69
+ const captured: string[] = [];
70
+ const consumer: EventConsumer = {
71
+ name: "test:consumer:mounted-now",
72
+ startFrom: "now",
73
+ handler: async (event) => {
74
+ captured.push(event.type);
75
+ },
76
+ };
77
+
78
+ // Mirrors mounting a new event-triggered workflow into a production app
79
+ // that already has a populated event log: the dispatcher (and its
80
+ // consumer) is only constructed AFTER the historical events above exist.
81
+ const dispatcher = createEventDispatcher({
82
+ db: testDb.db,
83
+ consumers: [consumer],
84
+ context: stubContext(),
85
+ });
86
+ await dispatcher.ensureRegistered();
87
+
88
+ const state = await getConsumerState(testDb.db, consumer.name);
89
+ expect(state?.lastProcessedEventId).toBe(headAtMount);
90
+ expect(state?.status).toBe("idle");
91
+
92
+ const result = await dispatcher.runOnce();
93
+ expect(result.byConsumer[consumer.name]).toEqual({ processed: 0, failed: 0 });
94
+ expect(captured).toHaveLength(0);
95
+
96
+ // Not just "not delivered" — proves the id > cursor contract directly:
97
+ // no historical row is even fetched on the first pass.
98
+ await testDb.db.begin(async (tx: DbTx) => {
99
+ const pending = await fetchPendingEvents(tx, headAtMount, 100);
100
+ expect(pending).toHaveLength(0);
101
+ });
102
+ });
103
+
104
+ test("without startFrom (default 'beginning') a freshly-registered consumer replays the full historical log", async () => {
105
+ await appendHistoricalEvent("start-from-test.four");
106
+ await appendHistoricalEvent("start-from-test.five");
107
+ const expectedReplayCount = historicalEventCount;
108
+
109
+ const captured: string[] = [];
110
+ const consumer: EventConsumer = {
111
+ name: "test:consumer:mounted-beginning",
112
+ handler: async (event) => {
113
+ captured.push(event.type);
114
+ },
115
+ };
116
+
117
+ const dispatcher = createEventDispatcher({
118
+ db: testDb.db,
119
+ consumers: [consumer],
120
+ context: stubContext(),
121
+ });
122
+ await dispatcher.ensureRegistered();
123
+
124
+ const stateBefore = await getConsumerState(testDb.db, consumer.name);
125
+ expect(stateBefore?.lastProcessedEventId).toBe(0n);
126
+
127
+ const result = await dispatcher.runOnce();
128
+ expect(result.byConsumer[consumer.name]).toEqual({ processed: expectedReplayCount, failed: 0 });
129
+ expect(captured).toEqual([
130
+ "start-from-test.one",
131
+ "start-from-test.two",
132
+ "start-from-test.three",
133
+ "start-from-test.four",
134
+ "start-from-test.five",
135
+ ]);
136
+ });
137
+
138
+ test("re-registering an already-registered consumer never clobbers an advanced cursor", async () => {
139
+ const consumer: EventConsumer = {
140
+ name: "test:consumer:advanced-then-remounted",
141
+ startFrom: "now",
142
+ handler: async () => {},
143
+ };
144
+
145
+ const dispatcher = createEventDispatcher({
146
+ db: testDb.db,
147
+ consumers: [consumer],
148
+ context: stubContext(),
149
+ });
150
+ await dispatcher.ensureRegistered();
151
+ const seeded = await getConsumerState(testDb.db, consumer.name);
152
+ expect(seeded?.lastProcessedEventId).toBe(BigInt(historicalEventCount));
153
+
154
+ await appendHistoricalEvent("start-from-test.six");
155
+ await dispatcher.runOnce();
156
+ const advanced = await getConsumerState(testDb.db, consumer.name);
157
+ expect(advanced?.lastProcessedEventId).toBe(BigInt(historicalEventCount));
158
+
159
+ // A new historical event exists again, not yet consumed by this consumer.
160
+ await appendHistoricalEvent("start-from-test.seven");
161
+
162
+ // A second dispatcher instance for the SAME consumer name simulates a
163
+ // process restart re-running the boot-time pre-registration — the row
164
+ // already exists, so ON CONFLICT DO NOTHING must leave its advanced
165
+ // cursor untouched, not reseed it at the new (higher) MAX(events.id).
166
+ const redeployedDispatcher = createEventDispatcher({
167
+ db: testDb.db,
168
+ consumers: [consumer],
169
+ context: stubContext(),
170
+ });
171
+ await redeployedDispatcher.ensureRegistered();
172
+
173
+ const afterRedeploy = await getConsumerState(testDb.db, consumer.name);
174
+ expect(afterRedeploy?.lastProcessedEventId).toBe(advanced?.lastProcessedEventId);
175
+ });
176
+ });
@@ -155,7 +155,7 @@ export async function preRegisterConsumers(
155
155
  ): Promise<void> {
156
156
  for (const consumer of consumers) {
157
157
  const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
158
- await insertConsumerIfAbsent(db, consumer.name, instanceId);
158
+ await insertConsumerIfAbsent(db, consumer.name, instanceId, consumer.startFrom);
159
159
  }
160
160
  }
161
161
 
@@ -94,6 +94,12 @@ export type EventConsumer = {
94
94
  // respect to shared storage (no DB writes), otherwise
95
95
  // each instance duplicates the effect.
96
96
  readonly delivery?: "shared" | "per-instance";
97
+ // Where the consumer's cursor starts on its FIRST registration. Default
98
+ // "beginning" replays the full log. "now" seeds the cursor at the current
99
+ // MAX(events.id) so mounting the consumer into an existing app does not
100
+ // retroactively fire side effects for historical events. Only affects the
101
+ // first registration — an existing cursor row is never clobbered.
102
+ readonly startFrom?: "beginning" | "now";
97
103
  };
98
104
 
99
105
  // Result of a dispatcher pass (runOnce / doPass). Shared across the public
@@ -11,10 +11,10 @@ export type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-st
11
11
 
12
12
  export type MultiStreamApplyContextDeps = {
13
13
  readonly registry: Registry;
14
- // TX-scoped DbRunner — the same `tx` the applyFn receives as the 2nd
15
- // arg. ctx.appendEvent + inline-projections run inside this tx so a
16
- // throw rolls the whole hop back (consumer retries the triggering
17
- // event on the next pass).
14
+ // Same DbRunner the applyFn receives as its 2nd arg. In the server's MSP
15
+ // consumer wiring (api/server.ts) that's the unbound pool, not the
16
+ // dispatcher's cursor tx — a throw does NOT roll back appends/inline
17
+ // projections already made; apply must be idempotent for the retry.
18
18
  readonly db: DbRunner;
19
19
  // tenantId + userId of the TRIGGERING event. appendEvent stamps these
20
20
  // onto the new event so the causal chain stays tenant-consistent and
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env bun
2
+ // `UnprocessableOpts.details` was tightened (fw#2460) to `& { readonly reason?:
3
+ // never }` — `reason` is owned exclusively by the ctor's first positional
4
+ // argument, which already folds it into `details` internally
5
+ // (`{ ...opts?.details, reason }`). Any consumer that ALSO puts `reason`
6
+ // inside its `details` object literal now fails with TS2322. This codemod
7
+ // removes that redundant `reason` property; the value is never lost, it was
8
+ // already duplicated from the positional argument.
9
+ //
10
+ // Usage: bun scripts/codemod/unprocessable-error-details-reason.ts <targetDir> [--dry-run]
11
+
12
+ import { type Dirent, readdirSync } from "node:fs";
13
+ import { join, relative, resolve } from "node:path";
14
+ import {
15
+ type Identifier,
16
+ Node,
17
+ type ObjectLiteralExpression,
18
+ Project,
19
+ type SourceFile,
20
+ SyntaxKind,
21
+ } from "ts-morph";
22
+
23
+ const EXCLUDE_DIRS = new Set(["node_modules", "dist", "build"]);
24
+ const KUMIKO_MODULE_RE = /^@cosmicdrift\/kumiko-/;
25
+
26
+ function findTargetFiles(rootDir: string): string[] {
27
+ // Walk the tree and skip excluded dirs while descending — filtering the
28
+ // absolute path after a full scan silently no-ops when the repo root
29
+ // itself contains "/build/" or "/node_modules/" (fw#2289).
30
+ const files: string[] = [];
31
+ const walk = (dir: string): void => {
32
+ let entries: Dirent[];
33
+ try {
34
+ entries = readdirSync(dir, { withFileTypes: true });
35
+ } catch {
36
+ // skip: unreadable directory during walk — treat as empty.
37
+ return;
38
+ }
39
+ for (const ent of entries) {
40
+ if (ent.isDirectory()) {
41
+ if (EXCLUDE_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
42
+ walk(join(dir, ent.name));
43
+ continue;
44
+ }
45
+ if (ent.isFile() && /\.(ts|tsx)$/.test(ent.name)) {
46
+ files.push(join(dir, ent.name));
47
+ }
48
+ }
49
+ };
50
+ walk(rootDir);
51
+ return files.sort();
52
+ }
53
+
54
+ type ImportedBinding = { readonly importedName: string; readonly moduleSpecifier: string };
55
+
56
+ /** Local-name -> where it's imported from, for every named value import in the file. */
57
+ function collectImportedNames(sourceFile: SourceFile): Map<string, ImportedBinding> {
58
+ const bindings = new Map<string, ImportedBinding>();
59
+ for (const imp of sourceFile.getImportDeclarations()) {
60
+ if (imp.isTypeOnly()) continue;
61
+ const moduleSpecifier = imp.getModuleSpecifierValue();
62
+ for (const spec of imp.getNamedImports()) {
63
+ if (spec.isTypeOnly()) continue;
64
+ const importedName = spec.getName();
65
+ const localName = spec.getAliasNode()?.getText() ?? importedName;
66
+ bindings.set(localName, { importedName, moduleSpecifier });
67
+ }
68
+ }
69
+ return bindings;
70
+ }
71
+
72
+ type DetailsResolution =
73
+ | { readonly kind: "object-literal"; readonly literal: ObjectLiteralExpression }
74
+ | { readonly kind: "unresolvable"; readonly note: string }
75
+ | { readonly kind: "absent" };
76
+
77
+ /** Resolves `opts.details` to a statically-inspectable object literal, or explains why it can't be. */
78
+ function resolveDetailsProperty(optsLiteral: ObjectLiteralExpression): DetailsResolution {
79
+ const prop = optsLiteral.getProperty("details");
80
+ if (!prop) return { kind: "absent" };
81
+
82
+ if (Node.isShorthandPropertyAssignment(prop)) {
83
+ return { kind: "unresolvable", note: "details is a shorthand property (a variable)" };
84
+ }
85
+ if (!Node.isPropertyAssignment(prop)) {
86
+ return { kind: "unresolvable", note: "details is not a plain property assignment" };
87
+ }
88
+ if (Node.isComputedPropertyName(prop.getNameNode())) {
89
+ return { kind: "unresolvable", note: "details key is computed" };
90
+ }
91
+ const init = prop.getInitializer();
92
+ if (!init) return { kind: "unresolvable", note: "details has no initializer" };
93
+ if (Node.isObjectLiteralExpression(init)) return { kind: "object-literal", literal: init };
94
+
95
+ const note = Node.isIdentifier(init)
96
+ ? `details is a variable ("${init.getText()}")`
97
+ : Node.isCallExpression(init)
98
+ ? "details is a call-expression result"
99
+ : Node.isConditionalExpression(init)
100
+ ? "details is a conditional expression"
101
+ : `details is not a statically-analyzable object literal (${init.getKindName()})`;
102
+ return { kind: "unresolvable", note };
103
+ }
104
+
105
+ /** True if removing this shorthand-bound identifier would leave its declaration with no other use. */
106
+ function wouldLeaveUnusedVariable(nameNode: Identifier): boolean {
107
+ try {
108
+ const refs = nameNode.findReferencesAsNodes();
109
+ const others = refs.filter((r) => r !== nameNode);
110
+ return others.length === 0;
111
+ } catch {
112
+ // Can't verify — treat as unsafe rather than guess.
113
+ return true;
114
+ }
115
+ }
116
+
117
+ type RemovalResult =
118
+ | { readonly kind: "removed" }
119
+ | { readonly kind: "absent" }
120
+ | { readonly kind: "skip"; readonly note: string };
121
+
122
+ /** Removes the `reason` property from a `details` object literal, in place. */
123
+ function removeReasonProperty(detailsLiteral: ObjectLiteralExpression): RemovalResult {
124
+ const properties = detailsLiteral.getProperties();
125
+ if (properties.some((p) => Node.isSpreadAssignment(p))) {
126
+ return {
127
+ kind: "skip",
128
+ note: "details object literal contains a spread — not statically decidable",
129
+ };
130
+ }
131
+
132
+ for (const prop of properties) {
133
+ if (Node.isPropertyAssignment(prop)) {
134
+ const nameNode = prop.getNameNode();
135
+ if (Node.isComputedPropertyName(nameNode)) {
136
+ const expr = nameNode.getExpression();
137
+ if (Node.isStringLiteral(expr) && expr.getLiteralText() === "reason") {
138
+ return { kind: "skip", note: "reason key is computed — not safe to remove mechanically" };
139
+ }
140
+ continue;
141
+ }
142
+ const propName = Node.isIdentifier(nameNode)
143
+ ? nameNode.getText()
144
+ : Node.isStringLiteral(nameNode)
145
+ ? nameNode.getLiteralText()
146
+ : undefined;
147
+ if (propName === "reason") {
148
+ prop.remove();
149
+ return { kind: "removed" };
150
+ }
151
+ continue;
152
+ }
153
+ if (Node.isShorthandPropertyAssignment(prop) && prop.getName() === "reason") {
154
+ if (wouldLeaveUnusedVariable(prop.getNameNode())) {
155
+ return {
156
+ kind: "skip",
157
+ note: "reason is a shorthand property; removing it would leave the bound variable unused",
158
+ };
159
+ }
160
+ prop.remove();
161
+ return { kind: "removed" };
162
+ }
163
+ }
164
+ return { kind: "absent" };
165
+ }
166
+
167
+ type CallOutcome = "changed" | "skipped" | "not-a-match";
168
+
169
+ function processNewExpression(args: readonly Node[], report: (note: string) => void): CallOutcome {
170
+ const optsArg = args[1];
171
+ if (!optsArg) return "not-a-match";
172
+
173
+ if (!Node.isObjectLiteralExpression(optsArg)) {
174
+ report(
175
+ "second argument (opts) is not a statically-analyzable object literal — cannot verify details.reason",
176
+ );
177
+ return "skipped";
178
+ }
179
+
180
+ const detailsRes = resolveDetailsProperty(optsArg);
181
+ if (detailsRes.kind === "absent") return "not-a-match";
182
+ if (detailsRes.kind === "unresolvable") {
183
+ report(detailsRes.note);
184
+ return "skipped";
185
+ }
186
+
187
+ const removal = removeReasonProperty(detailsRes.literal);
188
+ if (removal.kind === "absent") return "not-a-match";
189
+ if (removal.kind === "skip") {
190
+ report(removal.note);
191
+ return "skipped";
192
+ }
193
+ return "changed";
194
+ }
195
+
196
+ type SkipEntry = { readonly file: string; readonly line: number; readonly note: string };
197
+
198
+ /** Migrates one file in place. Returns how many `reason` properties were removed. */
199
+ function migrateFile(sourceFile: SourceFile, skips: SkipEntry[]): number {
200
+ const importedNames = collectImportedNames(sourceFile);
201
+ let removedCount = 0;
202
+
203
+ for (const newExpr of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) {
204
+ const callee = newExpr.getExpression();
205
+ if (!Node.isIdentifier(callee)) continue;
206
+ const localName = callee.getText();
207
+
208
+ const binding = importedNames.get(localName);
209
+ const isFrameworkUnprocessableError = binding?.importedName === "UnprocessableError";
210
+
211
+ if (!isFrameworkUnprocessableError) {
212
+ if (localName !== "UnprocessableError") continue;
213
+ const note = binding
214
+ ? `UnprocessableError is imported from "${binding.moduleSpecifier}", not a @cosmicdrift/kumiko-* package`
215
+ : "UnprocessableError has no @cosmicdrift/kumiko-* import in this file (likely a locally defined class)";
216
+ skips.push({ file: sourceFile.getFilePath(), line: newExpr.getStartLineNumber(), note });
217
+ continue;
218
+ }
219
+ if (!KUMIKO_MODULE_RE.test(binding.moduleSpecifier)) {
220
+ skips.push({
221
+ file: sourceFile.getFilePath(),
222
+ line: newExpr.getStartLineNumber(),
223
+ note: `UnprocessableError is imported from "${binding.moduleSpecifier}", not a @cosmicdrift/kumiko-* package`,
224
+ });
225
+ continue;
226
+ }
227
+
228
+ const outcome = processNewExpression(newExpr.getArguments(), (note) => {
229
+ skips.push({ file: sourceFile.getFilePath(), line: newExpr.getStartLineNumber(), note });
230
+ });
231
+ if (outcome === "changed") removedCount++;
232
+ }
233
+
234
+ return removedCount;
235
+ }
236
+
237
+ async function main(): Promise<void> {
238
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
239
+ const dryRun = process.argv.includes("--dry-run");
240
+ const rootDir = resolve(positional[0] ?? process.cwd());
241
+
242
+ const files = findTargetFiles(rootDir);
243
+ const project = new Project({
244
+ skipAddingFilesFromTsConfig: true,
245
+ skipFileDependencyResolution: true,
246
+ });
247
+
248
+ let touchedFiles = 0;
249
+ let removedTotal = 0;
250
+ const skips: SkipEntry[] = [];
251
+
252
+ for (const file of files) {
253
+ const sourceFile = project.addSourceFileAtPath(file);
254
+ const removed = migrateFile(sourceFile, skips);
255
+ if (removed === 0) continue;
256
+
257
+ touchedFiles++;
258
+ removedTotal += removed;
259
+ if (!dryRun) sourceFile.saveSync();
260
+ }
261
+
262
+ console.log(`\nScanned ${files.length} files under ${rootDir}${dryRun ? " (dry-run)" : ""}.`);
263
+ console.log(
264
+ `Touched ${touchedFiles} files, removed ${removedTotal} redundant "reason" propert(y/ies).`,
265
+ );
266
+ console.log(`Skipped ${skips.length} site(s):`);
267
+ for (const s of skips) {
268
+ console.log(` ${relative(rootDir, s.file)}:${s.line} — ${s.note}`);
269
+ }
270
+ console.log();
271
+ }
272
+
273
+ await main();