@cosmicdrift/kumiko-framework 0.213.0 → 0.215.0
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/package.json +3 -3
- package/src/__tests__/upgrade-cli.test.ts +41 -12
- package/src/api/__tests__/jwt.test.ts +12 -0
- package/src/api/__tests__/request-locale.integration.test.ts +53 -3
- package/src/api/auth-middleware.ts +1 -0
- package/src/api/jwt.ts +7 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +108 -0
- package/src/db/queries/event-store.ts +10 -5
- package/src/db/queries/projection-rebuild.ts +5 -3
- package/src/db/query.ts +1 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -1
- package/src/engine/boot-validator/pii-retention.ts +5 -6
- package/src/engine/feature-changelog.ts +1 -1
- package/src/pipeline/dispatch-shared.ts +8 -4
- package/src/scripts/codemod/README.md +36 -0
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +87 -0
- package/src/scripts/codemod/pii-personal-migration.ts +464 -0
- package/src/upgrade-cli.ts +27 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.215.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"./package.json": "./package.json"
|
|
195
195
|
},
|
|
196
196
|
"dependencies": {
|
|
197
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
197
|
+
"@cosmicdrift/kumiko-types": "0.215.0",
|
|
198
198
|
"bullmq": "^5.76.7",
|
|
199
199
|
"bun-types": "^1.3.13",
|
|
200
200
|
"hono": "^4.13.1",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"zod": "^4.4.3"
|
|
211
211
|
},
|
|
212
212
|
"devDependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
213
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.215.0",
|
|
214
214
|
"bun-types": "^1.3.13",
|
|
215
215
|
"pino-pretty": "^13.1.3"
|
|
216
216
|
},
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
symlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { tmpdir } from "node:os";
|
|
4
12
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
findCodemodScriptsRoot,
|
|
15
|
+
resolveCodemodScript,
|
|
16
|
+
runUpgradeCli,
|
|
17
|
+
type UpgradeCliOut,
|
|
18
|
+
} from "../upgrade-cli";
|
|
6
19
|
|
|
7
20
|
function makeSpyOutput(): {
|
|
8
21
|
readonly out: UpgradeCliOut;
|
|
@@ -165,9 +178,10 @@ describe("upgrade command — enterprise package layout", () => {
|
|
|
165
178
|
});
|
|
166
179
|
});
|
|
167
180
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
181
|
+
// Codemod scripts ship inside packages/framework/src/scripts/codemod — the
|
|
182
|
+
// published @cosmicdrift/kumiko-framework package (fw#2301).
|
|
170
183
|
const REAL_REPO_ROOT = join(import.meta.dir, "../../../..");
|
|
184
|
+
const REAL_FRAMEWORK_SRC = join(import.meta.dir, "..");
|
|
171
185
|
const REAL_CODEMOD = "scripts/codemod/crypto-shredding-testing-move.ts";
|
|
172
186
|
|
|
173
187
|
function breakingEntryWithCodemod(codemod: string | undefined): string {
|
|
@@ -190,31 +204,46 @@ const LEGACY_IMPORT_FIXTURE = [
|
|
|
190
204
|
|
|
191
205
|
describe("resolveCodemodScript", () => {
|
|
192
206
|
test("resolves a real script under scripts/codemod/", () => {
|
|
193
|
-
const resolved = resolveCodemodScript(
|
|
194
|
-
expect(resolved).toBe(join(
|
|
207
|
+
const resolved = resolveCodemodScript(REAL_FRAMEWORK_SRC, REAL_CODEMOD);
|
|
208
|
+
expect(resolved).toBe(join(REAL_FRAMEWORK_SRC, REAL_CODEMOD));
|
|
195
209
|
});
|
|
196
210
|
|
|
197
211
|
test("rejects an absolute path", () => {
|
|
198
|
-
expect(resolveCodemodScript(
|
|
212
|
+
expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "/etc/passwd.ts")).toBeNull();
|
|
199
213
|
});
|
|
200
214
|
|
|
201
215
|
test("rejects path traversal that escapes scripts/codemod/", () => {
|
|
202
216
|
expect(
|
|
203
|
-
resolveCodemodScript(
|
|
217
|
+
resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/../../package.json.ts"),
|
|
204
218
|
).toBeNull();
|
|
205
|
-
expect(resolveCodemodScript(
|
|
219
|
+
expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "../outside/x.ts")).toBeNull();
|
|
206
220
|
});
|
|
207
221
|
|
|
208
222
|
test("rejects a non-.ts file", () => {
|
|
209
|
-
expect(resolveCodemodScript(
|
|
223
|
+
expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/README.md")).toBeNull();
|
|
210
224
|
});
|
|
211
225
|
|
|
212
226
|
test("rejects a script that doesn't exist", () => {
|
|
213
|
-
expect(
|
|
227
|
+
expect(
|
|
228
|
+
resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/does-not-exist.ts"),
|
|
229
|
+
).toBeNull();
|
|
214
230
|
});
|
|
215
231
|
|
|
216
232
|
test("rejects an undefined codemod field", () => {
|
|
217
|
-
expect(resolveCodemodScript(
|
|
233
|
+
expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, undefined)).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("findCodemodScriptsRoot resolves through a hoisted node_modules symlink", () => {
|
|
237
|
+
const cwd = tmp({ "apps/web/package.json": "{}" });
|
|
238
|
+
const nmPkgDir = join(cwd, "node_modules/@cosmicdrift/kumiko-framework");
|
|
239
|
+
mkdirSync(join(nmPkgDir, ".."), { recursive: true });
|
|
240
|
+
symlinkSync(REAL_FRAMEWORK_SRC.replace(/\/src$/, ""), nmPkgDir, "dir");
|
|
241
|
+
|
|
242
|
+
const root = findCodemodScriptsRoot(join(cwd, "apps/web"));
|
|
243
|
+
expect(root).toBe(join(nmPkgDir, "src"));
|
|
244
|
+
|
|
245
|
+
const resolved = resolveCodemodScript(root!, REAL_CODEMOD);
|
|
246
|
+
expect(resolved).toBe(join(nmPkgDir, "src", REAL_CODEMOD));
|
|
218
247
|
});
|
|
219
248
|
});
|
|
220
249
|
|
|
@@ -94,6 +94,18 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
|
94
94
|
const payload = await jwt.verify(await jwt.sign(user));
|
|
95
95
|
expect(payload.timezone).toBeUndefined();
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
// fw#2333 — SessionUser.locale must survive the sign/verify roundtrip,
|
|
99
|
+
// same contract as timezone above.
|
|
100
|
+
it("round-trips the locale claim when set", async () => {
|
|
101
|
+
const payload = await jwt.verify(await jwt.sign({ ...user, locale: "de-DE" }));
|
|
102
|
+
expect(payload.locale).toBe("de-DE");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("omits the locale claim when unset", async () => {
|
|
106
|
+
const payload = await jwt.verify(await jwt.sign(user));
|
|
107
|
+
expect(payload.locale).toBeUndefined();
|
|
108
|
+
});
|
|
97
109
|
});
|
|
98
110
|
|
|
99
111
|
describe("createJwtHelper — keyring form", () => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// can't be exercised via createTestDispatcher, which skips the HTTP layer
|
|
5
5
|
// entirely.
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
-
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { defineFeature, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import {
|
|
9
9
|
createTestUser,
|
|
10
10
|
setupTestStack,
|
|
@@ -22,11 +22,15 @@ const localeProbe = defineFeature("locale-probe", (r) => {
|
|
|
22
22
|
);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
async function readLocale(
|
|
25
|
+
async function readLocale(
|
|
26
|
+
stack: TestStack,
|
|
27
|
+
headers: Record<string, string>,
|
|
28
|
+
user: SessionUser = createTestUser({ id: 1 }),
|
|
29
|
+
): Promise<string> {
|
|
26
30
|
const res = await stack.http.writeWithHeaders(
|
|
27
31
|
"locale-probe:write:read-locale",
|
|
28
32
|
{},
|
|
29
|
-
|
|
33
|
+
user,
|
|
30
34
|
headers,
|
|
31
35
|
);
|
|
32
36
|
const body = (await res.json()) as { data: { locale: string } };
|
|
@@ -94,3 +98,49 @@ describe("ctx.locale falls back to the app's boot-configured defaultLocale", ()
|
|
|
94
98
|
expect(locale).toBe("ja");
|
|
95
99
|
});
|
|
96
100
|
});
|
|
101
|
+
|
|
102
|
+
// fw#2333 — SessionUser.locale (persisted at login) sits between the
|
|
103
|
+
// request-layer signal and the boot default: no live request signal falls
|
|
104
|
+
// back to it, but a live signal still wins over it.
|
|
105
|
+
describe("ctx.locale falls back to SessionUser.locale ahead of the boot default", () => {
|
|
106
|
+
let stack: TestStack;
|
|
107
|
+
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
stack = await setupTestStack({
|
|
110
|
+
features: [localeProbe],
|
|
111
|
+
extraContext: { defaultLocale: "ja" },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterAll(async () => {
|
|
116
|
+
await stack.cleanup();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("no header signal falls back to the persisted SessionUser.locale", async () => {
|
|
120
|
+
const locale = await readLocale(stack, {}, createTestUser({ id: 1, locale: "de-DE" }));
|
|
121
|
+
expect(locale).toBe("de-DE");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("X-Locale header still wins over a persisted SessionUser.locale", async () => {
|
|
125
|
+
const locale = await readLocale(
|
|
126
|
+
stack,
|
|
127
|
+
{ [LOCALE_HEADER_NAME]: "fr-FR" },
|
|
128
|
+
createTestUser({ id: 1, locale: "de-DE" }),
|
|
129
|
+
);
|
|
130
|
+
expect(locale).toBe("fr-FR");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a forged/malformed SessionUser.locale falls through to the boot default", async () => {
|
|
134
|
+
const locale = await readLocale(
|
|
135
|
+
stack,
|
|
136
|
+
{},
|
|
137
|
+
createTestUser({ id: 1, locale: "<script>alert(1)</script>" }),
|
|
138
|
+
);
|
|
139
|
+
expect(locale).toBe("ja");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("no SessionUser.locale set falls through to the boot default", async () => {
|
|
143
|
+
const locale = await readLocale(stack, {}, createTestUser({ id: 1 }));
|
|
144
|
+
expect(locale).toBe("ja");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -324,6 +324,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
324
324
|
tenantId: payload.tenantId,
|
|
325
325
|
roles: derivedRoles ?? payload.roles,
|
|
326
326
|
...(payload.timezone ? { timezone: payload.timezone } : {}),
|
|
327
|
+
...(payload.locale ? { locale: payload.locale } : {}),
|
|
327
328
|
...(payload.claims ? { claims: payload.claims } : {}),
|
|
328
329
|
...(payload.jti ? { sid: payload.jti } : {}),
|
|
329
330
|
};
|
package/src/api/jwt.ts
CHANGED
|
@@ -13,6 +13,9 @@ export type JwtPayload = {
|
|
|
13
13
|
// IANA zone from user.timezone, set at login — see SessionUser.timezone
|
|
14
14
|
// (fw#1636). Absent → ctx.tz.user falls back to ctx.tz.tenant.
|
|
15
15
|
timezone?: string;
|
|
16
|
+
// BCP-47 tag from user.locale, set at login — see SessionUser.locale
|
|
17
|
+
// (fw#2333). Absent → ctx.locale falls back further down the chain.
|
|
18
|
+
locale?: string;
|
|
16
19
|
// Optional — present when a feature has registered auth claims via the
|
|
17
20
|
// `r.authClaims()` hook system. Absent for stateless-JWT deployments
|
|
18
21
|
// without auth-claims wiring.
|
|
@@ -110,6 +113,7 @@ export function createJwtHelper(
|
|
|
110
113
|
roles: [...user.roles],
|
|
111
114
|
};
|
|
112
115
|
if (user.timezone) body.timezone = user.timezone;
|
|
116
|
+
if (user.locale) body.locale = user.locale;
|
|
113
117
|
if (user.claims) body.claims = { ...user.claims };
|
|
114
118
|
|
|
115
119
|
const header: jose.JWTHeaderParameters = keyring.signKid
|
|
@@ -162,6 +166,9 @@ export function createJwtHelper(
|
|
|
162
166
|
if (typeof payload["timezone"] === "string") {
|
|
163
167
|
result.timezone = payload["timezone"];
|
|
164
168
|
}
|
|
169
|
+
if (typeof payload["locale"] === "string") {
|
|
170
|
+
result.locale = payload["locale"];
|
|
171
|
+
}
|
|
165
172
|
const claims = payload["claims"];
|
|
166
173
|
if (claims && typeof claims === "object") {
|
|
167
174
|
result.claims = claims as DbRow;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// #2323: event-store.ts's and projection-rebuild.ts's plain SELECT helpers
|
|
2
|
+
// read via asRawClient(db).unsafe() directly, bypassing the #1163
|
|
3
|
+
// closed-connection retry that only covered bun-db/query.ts's own
|
|
4
|
+
// selectMany/countWhere. Routed the non-locking read call sites through
|
|
5
|
+
// unsafeReadRetrying instead — this test mirrors
|
|
6
|
+
// bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
|
|
7
|
+
// the retry now fires. Writes (insertSubsequentEventRow, upsertSnapshot,
|
|
8
|
+
// markProjectionRebuilding, ...) stay unretried per #1358, and the
|
|
9
|
+
// FOR UPDATE / FOR UPDATE SKIP LOCKED reads in event-consumer.ts are always
|
|
10
|
+
// called inside transaction() (verified against their only call sites) — the
|
|
11
|
+
// retry guard there is a no-op, so those are left as asRawClient calls too.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import {
|
|
15
|
+
selectAggregateMaxVersion,
|
|
16
|
+
selectEventsHighWaterMark,
|
|
17
|
+
selectNextEventIdAfter,
|
|
18
|
+
selectStreamMaxVersion,
|
|
19
|
+
} from "../event-store";
|
|
20
|
+
import {
|
|
21
|
+
countSubscribedEvents,
|
|
22
|
+
selectEventsForProjectionRebuildBatch,
|
|
23
|
+
} from "../projection-rebuild";
|
|
24
|
+
|
|
25
|
+
function closedConnectionError(): Error {
|
|
26
|
+
return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type FakeClient = {
|
|
30
|
+
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
31
|
+
begin: () => never;
|
|
32
|
+
calls: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
|
|
36
|
+
const remaining = [...failures];
|
|
37
|
+
const client: FakeClient = {
|
|
38
|
+
calls: 0,
|
|
39
|
+
unsafe: async () => {
|
|
40
|
+
client.calls++;
|
|
41
|
+
const err = remaining.shift();
|
|
42
|
+
if (err) throw err;
|
|
43
|
+
return [row];
|
|
44
|
+
},
|
|
45
|
+
begin: () => {
|
|
46
|
+
throw new Error("not used in test");
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return client;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("framework db/queries — closed-connection retry (#2323)", () => {
|
|
53
|
+
test("selectStreamMaxVersion retries once and returns the version", async () => {
|
|
54
|
+
const db = fakeClient([closedConnectionError()], { v: 5 });
|
|
55
|
+
const result = await selectStreamMaxVersion(db as never, "agg1", "t1");
|
|
56
|
+
expect(result).toBe(5);
|
|
57
|
+
expect(db.calls).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("selectAggregateMaxVersion retries once and returns the version", async () => {
|
|
61
|
+
const db = fakeClient([closedConnectionError()], { v: 7 });
|
|
62
|
+
const result = await selectAggregateMaxVersion(db as never, "agg1");
|
|
63
|
+
expect(result).toBe(7);
|
|
64
|
+
expect(db.calls).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("selectEventsHighWaterMark retries once and returns the max id", async () => {
|
|
68
|
+
const db = fakeClient([closedConnectionError()], { max: 42n });
|
|
69
|
+
const result = await selectEventsHighWaterMark(db as never);
|
|
70
|
+
expect(result).toBe(42n);
|
|
71
|
+
expect(db.calls).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("selectNextEventIdAfter retries once and returns the next id", async () => {
|
|
75
|
+
const db = fakeClient([closedConnectionError()], { id: 43n });
|
|
76
|
+
const result = await selectNextEventIdAfter(db as never, 42n);
|
|
77
|
+
expect(result).toBe(43n);
|
|
78
|
+
expect(db.calls).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("selectEventsForProjectionRebuildBatch retries once and returns rows", async () => {
|
|
82
|
+
const db = fakeClient([closedConnectionError()], { id: "1", type: "created" });
|
|
83
|
+
const rows = await selectEventsForProjectionRebuildBatch(
|
|
84
|
+
db as never,
|
|
85
|
+
["user"],
|
|
86
|
+
["user:created"],
|
|
87
|
+
0n,
|
|
88
|
+
100,
|
|
89
|
+
);
|
|
90
|
+
expect(rows).toHaveLength(1);
|
|
91
|
+
expect(db.calls).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("countSubscribedEvents retries once and returns the count", async () => {
|
|
95
|
+
const db = fakeClient([closedConnectionError()], { n: 12n });
|
|
96
|
+
const result = await countSubscribedEvents(db as never, ["user"], ["user:created"]);
|
|
97
|
+
expect(result).toBe(12n);
|
|
98
|
+
expect(db.calls).toBe(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("gives up after the single retry when the connection stays closed", async () => {
|
|
102
|
+
const db = fakeClient([closedConnectionError(), closedConnectionError()], { v: 5 });
|
|
103
|
+
await expect(selectStreamMaxVersion(db as never, "agg1", "t1")).rejects.toThrow(
|
|
104
|
+
"connection was closed",
|
|
105
|
+
);
|
|
106
|
+
expect(db.calls).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
isUniqueViolation,
|
|
6
6
|
} from "../pg-error";
|
|
7
7
|
import type { AnyDb } from "../query";
|
|
8
|
-
import { asRawClient } from "../query";
|
|
8
|
+
import { asRawClient, unsafeReadRetrying } from "../query";
|
|
9
9
|
|
|
10
10
|
/** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
|
|
11
11
|
export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void> {
|
|
@@ -175,7 +175,8 @@ export async function selectStreamMaxVersion(
|
|
|
175
175
|
aggregateId: string,
|
|
176
176
|
tenantId: string,
|
|
177
177
|
): Promise<number> {
|
|
178
|
-
const rows = (await
|
|
178
|
+
const rows = (await unsafeReadRetrying(
|
|
179
|
+
db,
|
|
179
180
|
`SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`,
|
|
180
181
|
[aggregateId, tenantId],
|
|
181
182
|
)) as ReadonlyArray<{ v: number | null }>;
|
|
@@ -184,7 +185,8 @@ export async function selectStreamMaxVersion(
|
|
|
184
185
|
|
|
185
186
|
/** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
|
|
186
187
|
export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string): Promise<number> {
|
|
187
|
-
const rows = (await
|
|
188
|
+
const rows = (await unsafeReadRetrying(
|
|
189
|
+
db,
|
|
188
190
|
`SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`,
|
|
189
191
|
[aggregateId],
|
|
190
192
|
)) as ReadonlyArray<{ v: number | null }>;
|
|
@@ -192,8 +194,10 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
|
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
|
|
195
|
-
const rows = (await
|
|
197
|
+
const rows = (await unsafeReadRetrying(
|
|
198
|
+
db,
|
|
196
199
|
`SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
|
|
200
|
+
[],
|
|
197
201
|
)) as ReadonlyArray<{ max: bigint | string | number | null }>;
|
|
198
202
|
const raw = rows[0]?.max;
|
|
199
203
|
if (typeof raw === "bigint") return raw;
|
|
@@ -208,7 +212,8 @@ export async function selectEventsHeadId(db: AnyDb): Promise<bigint> {
|
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
export async function selectNextEventIdAfter(db: AnyDb, afterId: bigint): Promise<bigint | null> {
|
|
211
|
-
const rows = (await
|
|
215
|
+
const rows = (await unsafeReadRetrying(
|
|
216
|
+
db,
|
|
212
217
|
`SELECT "id" FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT 1`,
|
|
213
218
|
[afterId],
|
|
214
219
|
)) as ReadonlyArray<{ id: string | bigint }>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnyDb } from "../query";
|
|
2
|
-
import { asRawClient } from "../query";
|
|
2
|
+
import { asRawClient, unsafeReadRetrying } from "../query";
|
|
3
3
|
|
|
4
4
|
export async function markProjectionRebuilding(db: AnyDb, projectionName: string): Promise<void> {
|
|
5
5
|
await asRawClient(db).unsafe(
|
|
@@ -26,7 +26,8 @@ export async function selectEventsForProjectionRebuildBatch(
|
|
|
26
26
|
// Archived streams don't replay (Marten-aligned): their aggregates are
|
|
27
27
|
// frozen ops-tombstones — replaying them would resurrect rows (or, for
|
|
28
28
|
// stranded duplicate-aggregates like fw#832, collide on unique indexes).
|
|
29
|
-
return (await
|
|
29
|
+
return (await unsafeReadRetrying(
|
|
30
|
+
db,
|
|
30
31
|
`SELECT * FROM "kumiko_events" e
|
|
31
32
|
WHERE e."aggregate_type" = ANY($1::text[])
|
|
32
33
|
AND e."type" = ANY($2::text[])
|
|
@@ -53,7 +54,8 @@ export async function countSubscribedEvents(
|
|
|
53
54
|
// Same archived-streams exclusion as the batch query — the #443 recompute
|
|
54
55
|
// compares this count against applied events; a filter mismatch would make
|
|
55
56
|
// every rebuild with an archived stream loop the full-re-replay forever.
|
|
56
|
-
const rows = (await
|
|
57
|
+
const rows = (await unsafeReadRetrying(
|
|
58
|
+
db,
|
|
57
59
|
`SELECT count(*)::bigint AS n FROM "kumiko_events" e
|
|
58
60
|
WHERE e."aggregate_type" = ANY($1::text[])
|
|
59
61
|
AND e."type" = ANY($2::text[])
|
package/src/db/query.ts
CHANGED
|
@@ -700,7 +700,7 @@ describe("validateBoot — retention", () => {
|
|
|
700
700
|
expect(matchingWarn).toBeUndefined();
|
|
701
701
|
});
|
|
702
702
|
|
|
703
|
-
test("blockDelete with only a subjectRef-only field and no anonymize
|
|
703
|
+
test("blockDelete with only a subjectRef-only field and no anonymize stays silent (#1645, narrowed by #2336)", () => {
|
|
704
704
|
const feature = defineFeature("test", (r) => {
|
|
705
705
|
r.entity(
|
|
706
706
|
"lease",
|
|
@@ -718,6 +718,31 @@ describe("validateBoot — retention", () => {
|
|
|
718
718
|
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
719
719
|
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
720
720
|
);
|
|
721
|
+
expect(matchingWarn).toBeUndefined();
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("blockDelete with a subjectRef field plus an anonymizable subject field and no anonymize still warns (#2336)", () => {
|
|
725
|
+
const feature = defineFeature("test", (r) => {
|
|
726
|
+
r.entity(
|
|
727
|
+
"lease",
|
|
728
|
+
createEntity({
|
|
729
|
+
fields: {
|
|
730
|
+
authorId: createTextField({
|
|
731
|
+
personal: "ref",
|
|
732
|
+
}),
|
|
733
|
+
customerName: createTextField({
|
|
734
|
+
personal: "self",
|
|
735
|
+
find: "none",
|
|
736
|
+
}),
|
|
737
|
+
},
|
|
738
|
+
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
validateBoot([feature]);
|
|
743
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
744
|
+
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
745
|
+
);
|
|
721
746
|
expect(matchingWarn).toBeDefined();
|
|
722
747
|
});
|
|
723
748
|
|
|
@@ -21,11 +21,10 @@ const FRAMEWORK_TIMESTAMP_FIELDS: ReadonlySet<string> = new Set([
|
|
|
21
21
|
// werden statt erst beim ersten Cleanup-Run.
|
|
22
22
|
const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.subjectRef);
|
|
24
|
+
// Excludes subjectRef: its `personal: "ref"` union member structurally
|
|
25
|
+
// forbids `anonymize` (packages/types/src/fields.ts) — #2336.
|
|
26
|
+
function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
|
|
27
|
+
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned);
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
@@ -222,7 +221,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
222
221
|
// blockDelete on an entity with no subject field is the correct
|
|
223
222
|
// "never auto-delete" choice; User-Forget never reaches those rows (#1622).
|
|
224
223
|
const hasSubjectField = Object.values(fieldsByName).some(
|
|
225
|
-
(f) =>
|
|
224
|
+
(f) => hasAnonymizableSubjectField(f as ResolvedPiiFlags), // @cast-boundary schema-walk
|
|
226
225
|
);
|
|
227
226
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
228
227
|
const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
@@ -15,7 +15,7 @@ export type ChangelogEntry = {
|
|
|
15
15
|
readonly detail?: string;
|
|
16
16
|
/** Required when type=breaking. Shown in `kumiko upgrade` output. */
|
|
17
17
|
readonly migration?: string;
|
|
18
|
-
/** Path (
|
|
18
|
+
/** Path (relative to packages/framework/src/, under scripts/codemod/) run by `kumiko upgrade --apply`. */
|
|
19
19
|
readonly codemod?: string;
|
|
20
20
|
};
|
|
21
21
|
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
} from "../event-store/snapshot";
|
|
52
52
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
|
|
53
53
|
import { createFileContext } from "../files/file-handle";
|
|
54
|
-
import { DEFAULT_LOCALE } from "../i18n/request-locale";
|
|
54
|
+
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale";
|
|
55
55
|
import {
|
|
56
56
|
createMetricsHandle,
|
|
57
57
|
createNoopMetricsHandle,
|
|
@@ -623,9 +623,13 @@ export async function buildHandlerContext(
|
|
|
623
623
|
|
|
624
624
|
// ctx.locale — request-layer signal (X-Locale header → Accept-Language,
|
|
625
625
|
// resolved once at the HTTP boundary by request-id-middleware.ts) wins;
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
626
|
+
// then SessionUser.locale (set at login, see fw#2333 — stale until
|
|
627
|
+
// re-login, same tradeoff as ctx.tz.user); falls back to the app's
|
|
628
|
+
// boot-configured defaultLocale, then DEFAULT_LOCALE. Mirrors ctx.tz's
|
|
629
|
+
// Request → Session → Boot-Default chain above.
|
|
630
|
+
const safeUserLocale =
|
|
631
|
+
user.locale !== undefined && isValidLocaleTag(user.locale) ? user.locale : undefined;
|
|
632
|
+
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
629
633
|
|
|
630
634
|
return {
|
|
631
635
|
...context,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# consumer-facing codemods
|
|
2
|
+
|
|
3
|
+
This directory holds the codemod scripts `kumiko upgrade --apply` runs for
|
|
4
|
+
consumers of `@cosmicdrift/kumiko-framework`. It ships as part of the
|
|
5
|
+
published package (under `src/`), unlike `scripts/codemod/` at the repo
|
|
6
|
+
root, which is internal bun-cutover tooling for this repo's own migration
|
|
7
|
+
and never gets published.
|
|
8
|
+
|
|
9
|
+
`crypto-shredding-testing-move.ts` rewrites the moved
|
|
10
|
+
`resetPiiSubjectKmsForTests` import path; it's wired into
|
|
11
|
+
`packages/bundled-features/src/crypto-shredding/changes.json`'s `codemod`
|
|
12
|
+
field and runs automatically via `kumiko upgrade --apply`.
|
|
13
|
+
|
|
14
|
+
## pii-personal-migration.ts
|
|
15
|
+
|
|
16
|
+
Migrates `create*Field(...)` calls from the old flag-based PII API (`pii`,
|
|
17
|
+
`userOwned`, `tenantOwned`, `subjectRef`, `allowPlaintext`, `lookupable`,
|
|
18
|
+
`searchable`, `sensitive`) to the author-facing `personal`/`find` API
|
|
19
|
+
(kumiko-framework#2250). Idempotent — safe to re-run against already
|
|
20
|
+
migrated code (no-ops on fields carrying `personal`).
|
|
21
|
+
|
|
22
|
+
`createTenantConfig`/`createUserConfig`/etc. are out of scope by
|
|
23
|
+
construction (name doesn't match `create*Field`).
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Anything it can't map mechanically (two subject flags on one field,
|
|
30
|
+
`sensitive` + `lookupable`/`searchable` together, `piiEncrypted` on an
|
|
31
|
+
entity field, a raw object literal with subject flags outside a
|
|
32
|
+
`create*Field(...)` call) is reported with file:line instead of guessed
|
|
33
|
+
at — fix those by hand. The report also lists every field that newly
|
|
34
|
+
gains `lookupable` (searchable-only fields resolving to `find: "fuzzy"`)
|
|
35
|
+
— each of those needs a `_bidx` column migration, which this script does
|
|
36
|
+
not write.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Rewrites `import { resetPiiSubjectKmsForTests, resetBlindIndexKeyForTests }
|
|
3
|
+
// from "@cosmicdrift/kumiko-framework/crypto"` to import both names from
|
|
4
|
+
// "@cosmicdrift/kumiko-framework/testing" instead (fw#1631). Only the
|
|
5
|
+
// package barrel specifier is rewritten — relative deep-imports of the
|
|
6
|
+
// defining module are left untouched, per the changes.json migration note.
|
|
7
|
+
// Idempotent: an already-migrated file has nothing left to move.
|
|
8
|
+
//
|
|
9
|
+
// Usage: bun scripts/codemod/crypto-shredding-testing-move.ts <targetDir> [--dry-run]
|
|
10
|
+
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { Glob } from "bun";
|
|
13
|
+
import { Project } from "ts-morph";
|
|
14
|
+
|
|
15
|
+
const OLD_SPECIFIER = "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
+
const NEW_SPECIFIER = "@cosmicdrift/kumiko-framework/testing";
|
|
17
|
+
const MOVED_NAMES = new Set(["resetPiiSubjectKmsForTests", "resetBlindIndexKeyForTests"]);
|
|
18
|
+
|
|
19
|
+
function findTargetFiles(rootDir: string): string[] {
|
|
20
|
+
const glob = new Glob("**/*.{ts,tsx}");
|
|
21
|
+
const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
|
|
22
|
+
const files: string[] = [];
|
|
23
|
+
for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
|
|
24
|
+
const abs = resolve(rootDir, file);
|
|
25
|
+
if (EXCLUDE.some((p) => abs.includes(p))) continue;
|
|
26
|
+
files.push(abs);
|
|
27
|
+
}
|
|
28
|
+
return files.sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main(): Promise<void> {
|
|
32
|
+
const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
33
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
34
|
+
const rootDir = resolve(positional[0] ?? process.cwd());
|
|
35
|
+
|
|
36
|
+
const files = findTargetFiles(rootDir);
|
|
37
|
+
const project = new Project({
|
|
38
|
+
skipAddingFilesFromTsConfig: true,
|
|
39
|
+
skipFileDependencyResolution: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let touchedFiles = 0;
|
|
43
|
+
let movedNames = 0;
|
|
44
|
+
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const sourceFile = project.addSourceFileAtPath(file);
|
|
47
|
+
const oldImport = sourceFile
|
|
48
|
+
.getImportDeclarations()
|
|
49
|
+
.find((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER);
|
|
50
|
+
if (!oldImport) continue;
|
|
51
|
+
|
|
52
|
+
const movedHere = oldImport.getNamedImports().filter((spec) => MOVED_NAMES.has(spec.getName()));
|
|
53
|
+
if (movedHere.length === 0) continue;
|
|
54
|
+
|
|
55
|
+
const names = movedHere.map((spec) => spec.getName());
|
|
56
|
+
|
|
57
|
+
const existingNewImport = sourceFile
|
|
58
|
+
.getImportDeclarations()
|
|
59
|
+
.find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER);
|
|
60
|
+
if (existingNewImport) {
|
|
61
|
+
const already = new Set(existingNewImport.getNamedImports().map((s) => s.getName()));
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
if (!already.has(name)) existingNewImport.addNamedImport(name);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
sourceFile.addImportDeclaration({ moduleSpecifier: NEW_SPECIFIER, namedImports: names });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const spec of movedHere) spec.remove();
|
|
70
|
+
const remaining =
|
|
71
|
+
oldImport.getNamedImports().length > 0 ||
|
|
72
|
+
!!oldImport.getDefaultImport() ||
|
|
73
|
+
!!oldImport.getNamespaceImport();
|
|
74
|
+
if (!remaining) oldImport.remove();
|
|
75
|
+
|
|
76
|
+
touchedFiles++;
|
|
77
|
+
movedNames += names.length;
|
|
78
|
+
if (!dryRun) sourceFile.saveSync();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(`\nScanned ${files.length} files under ${rootDir}${dryRun ? " (dry-run)" : ""}.`);
|
|
82
|
+
console.log(
|
|
83
|
+
`Touched ${touchedFiles} files, moved ${movedNames} import(s) from "${OLD_SPECIFIER}" to "${NEW_SPECIFIER}".\n`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await main();
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Migrates create*Field(...) calls from the old flag-based PII API (pii,
|
|
3
|
+
// userOwned, tenantOwned, subjectRef, allowPlaintext, lookupable,
|
|
4
|
+
// searchable, sensitive) to the author-facing personal/find API
|
|
5
|
+
// (kumiko-framework#2250). Idempotent — re-running is a no-op on already
|
|
6
|
+
// migrated fields (skipped once a `personal` property is present).
|
|
7
|
+
//
|
|
8
|
+
// Mapping table (exact):
|
|
9
|
+
// pii: true -> personal: "self"
|
|
10
|
+
// userOwned: { ownerField: "x" } -> personal: { of: "x" }
|
|
11
|
+
// tenantOwned: true -> personal: "tenant"
|
|
12
|
+
// subjectRef: true -> personal: "ref"
|
|
13
|
+
// allowPlaintext: "R" -> personal: false, reason: "<R, normalized to snake_case>"
|
|
14
|
+
// lookupable: true (alone) -> find: "exact"
|
|
15
|
+
// lookupable: true + searchable: true -> find: "fuzzy"
|
|
16
|
+
// searchable: true (alone) -> find: "fuzzy"
|
|
17
|
+
// sensitive: true -> find: "secret"
|
|
18
|
+
// none of the above -> find: "none"
|
|
19
|
+
// `find` only applies to text/longText fields, and only when the subject
|
|
20
|
+
// is neither "ref" nor `personal: false` (see PersonalAnnotations in
|
|
21
|
+
// packages/types/src/fields.ts). Never guesses: anything outside this
|
|
22
|
+
// table is reported (file:line) instead of transformed.
|
|
23
|
+
//
|
|
24
|
+
// Usage: bun scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
|
|
25
|
+
|
|
26
|
+
import { relative, resolve } from "node:path";
|
|
27
|
+
import { Glob } from "bun";
|
|
28
|
+
import {
|
|
29
|
+
Node,
|
|
30
|
+
type ObjectLiteralExpression,
|
|
31
|
+
Project,
|
|
32
|
+
type PropertyAssignment,
|
|
33
|
+
type SourceFile,
|
|
34
|
+
SyntaxKind,
|
|
35
|
+
} from "ts-morph";
|
|
36
|
+
|
|
37
|
+
const SUBJECT_FLAG_NAMES = [
|
|
38
|
+
"pii",
|
|
39
|
+
"userOwned",
|
|
40
|
+
"tenantOwned",
|
|
41
|
+
"subjectRef",
|
|
42
|
+
"allowPlaintext",
|
|
43
|
+
] as const;
|
|
44
|
+
type SubjectFlagName = (typeof SUBJECT_FLAG_NAMES)[number];
|
|
45
|
+
|
|
46
|
+
// `create*Field` factories that accept `find` (text/longText) — see
|
|
47
|
+
// packages/framework/src/engine/factories.ts.
|
|
48
|
+
const TEXT_FIND_FACTORIES = new Set(["createTextField"]);
|
|
49
|
+
const LONGTEXT_FIND_FACTORIES = new Set(["createLongTextField"]);
|
|
50
|
+
// Accept `personal` but never `find` (PersonalAnnotationsNoFind).
|
|
51
|
+
const NO_FIND_FACTORIES = new Set([
|
|
52
|
+
"createSelectField",
|
|
53
|
+
"createMultiSelectField",
|
|
54
|
+
"createNumberField",
|
|
55
|
+
"createBigIntField",
|
|
56
|
+
"createDecimalField",
|
|
57
|
+
"createEmbeddedField",
|
|
58
|
+
"createEmbeddedListField",
|
|
59
|
+
"createJsonbField",
|
|
60
|
+
"createDateField",
|
|
61
|
+
"createTimestampField",
|
|
62
|
+
"createTzField",
|
|
63
|
+
"createLocatedTimestampField",
|
|
64
|
+
]);
|
|
65
|
+
// No personal-annotation support at all — a subject flag here is a bug.
|
|
66
|
+
const NO_PERSONAL_FACTORIES = new Set([
|
|
67
|
+
"createBooleanField",
|
|
68
|
+
"createMoneyField",
|
|
69
|
+
"createFileField",
|
|
70
|
+
"createImageField",
|
|
71
|
+
"createFilesField",
|
|
72
|
+
"createImagesField",
|
|
73
|
+
"createDerivedField",
|
|
74
|
+
]);
|
|
75
|
+
// Overrides live in argument position 1, not 0.
|
|
76
|
+
const OVERRIDES_ARG_INDEX_1 = new Set(["createEmbeddedField", "createEmbeddedListField"]);
|
|
77
|
+
|
|
78
|
+
const FIELD_FACTORY_RE = /^create\w*Field$/;
|
|
79
|
+
|
|
80
|
+
type ReportEntry = { readonly file: string; readonly line: number; readonly reason: string };
|
|
81
|
+
type FindBucket = "exact" | "fuzzy" | "none" | "secret" | "ref" | "personal-false" | "no-find";
|
|
82
|
+
|
|
83
|
+
const reports: ReportEntry[] = [];
|
|
84
|
+
const newLookupableSites: ReportEntry[] = [];
|
|
85
|
+
const counts: Record<FindBucket, number> = {
|
|
86
|
+
exact: 0,
|
|
87
|
+
fuzzy: 0,
|
|
88
|
+
none: 0,
|
|
89
|
+
secret: 0,
|
|
90
|
+
ref: 0,
|
|
91
|
+
"personal-false": 0,
|
|
92
|
+
"no-find": 0,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function report(node: Node, reason: string): void {
|
|
96
|
+
reports.push({
|
|
97
|
+
file: node.getSourceFile().getFilePath(),
|
|
98
|
+
line: node.getStartLineNumber(),
|
|
99
|
+
reason,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isTrueLiteral(node: Node | undefined): boolean {
|
|
104
|
+
return !!node && node.getKind() === SyntaxKind.TrueKeyword;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findProp(obj: ObjectLiteralExpression, name: string): PropertyAssignment | undefined {
|
|
108
|
+
const p = obj.getProperty(name);
|
|
109
|
+
return p && Node.isPropertyAssignment(p) ? p : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Reason-string convention (infra/guards/guard-error-reasons.ts):
|
|
113
|
+
// ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ — lowercase snake_case, optional
|
|
114
|
+
// dot-namespacing. Old allowPlaintext values are free-form kebab-case.
|
|
115
|
+
function normalizeReasonSlug(raw: string): string {
|
|
116
|
+
const slug = raw
|
|
117
|
+
.trim()
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/[^a-z0-9.]+/g, "_")
|
|
120
|
+
.replace(/_+/g, "_")
|
|
121
|
+
.replace(/^[._]+|[._]+$/g, "");
|
|
122
|
+
return /^[0-9]/.test(slug) ? `_${slug}` : slug;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type FieldCall = { readonly name: string; readonly objArg: ObjectLiteralExpression };
|
|
126
|
+
|
|
127
|
+
function collectFieldCalls(sourceFile: SourceFile): FieldCall[] {
|
|
128
|
+
const calls: FieldCall[] = [];
|
|
129
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
130
|
+
const exprNode = call.getExpression();
|
|
131
|
+
if (!Node.isIdentifier(exprNode)) continue;
|
|
132
|
+
const name = exprNode.getText();
|
|
133
|
+
// `createTenantConfig`/`createUserConfig`/etc. never match — they
|
|
134
|
+
// don't end in "Field", so they're out of scope by construction.
|
|
135
|
+
if (!FIELD_FACTORY_RE.test(name)) continue;
|
|
136
|
+
|
|
137
|
+
const argIndex = OVERRIDES_ARG_INDEX_1.has(name) ? 1 : 0;
|
|
138
|
+
const objArg = call.getArguments()[argIndex];
|
|
139
|
+
if (objArg && Node.isObjectLiteralExpression(objArg)) {
|
|
140
|
+
calls.push({ name, objArg });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return calls;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function reportRawSubjectLiterals(
|
|
147
|
+
sourceFile: SourceFile,
|
|
148
|
+
handled: ReadonlySet<ObjectLiteralExpression>,
|
|
149
|
+
): void {
|
|
150
|
+
for (const objLit of sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
151
|
+
if (handled.has(objLit)) continue;
|
|
152
|
+
const found = [...SUBJECT_FLAG_NAMES, "piiEncrypted"].filter((n) => objLit.getProperty(n));
|
|
153
|
+
if (found.length > 0) {
|
|
154
|
+
report(
|
|
155
|
+
objLit,
|
|
156
|
+
`subject flag(s) [${found.join(", ")}] on an object literal that is not a create*Field(...) call`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// kumiko-lint-ignore complexity-budget migration codemod, one-time script — splitting would add risk without benefit
|
|
163
|
+
function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string): void {
|
|
164
|
+
// skip: field already carries `personal` — already migrated, idempotent no-op
|
|
165
|
+
if (obj.getProperty("personal")) return;
|
|
166
|
+
|
|
167
|
+
const piiEncryptedProp = obj.getProperty("piiEncrypted");
|
|
168
|
+
if (piiEncryptedProp) {
|
|
169
|
+
report(
|
|
170
|
+
piiEncryptedProp,
|
|
171
|
+
"piiEncrypted: true on an entity field (removed from the type system) — needs a human decision, not a mechanical mapping",
|
|
172
|
+
);
|
|
173
|
+
// skip: reported above — piiEncrypted needs a human decision, not a mechanical mapping
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const subjectProps = SUBJECT_FLAG_NAMES.map((n) => ({ name: n, prop: findProp(obj, n) })).filter(
|
|
178
|
+
(x): x is { name: SubjectFlagName; prop: PropertyAssignment } => !!x.prop,
|
|
179
|
+
);
|
|
180
|
+
const lookupableProp = findProp(obj, "lookupable");
|
|
181
|
+
const searchableProp = findProp(obj, "searchable");
|
|
182
|
+
const sensitiveProp = findProp(obj, "sensitive");
|
|
183
|
+
const anonymizeProp = obj.getProperty("anonymize");
|
|
184
|
+
|
|
185
|
+
if (subjectProps.length === 0) {
|
|
186
|
+
// `searchable`/`sensitive` alone remain valid, unrelated FieldDef
|
|
187
|
+
// properties — left untouched. `lookupable` alone can never compile
|
|
188
|
+
// (always an excess property without a `personal` to attach `find`
|
|
189
|
+
// to). `anonymize` alone has no PersonalAnnotations arm to live on.
|
|
190
|
+
if (lookupableProp) {
|
|
191
|
+
report(
|
|
192
|
+
lookupableProp,
|
|
193
|
+
"lookupable without any subject annotation — no `personal` to attach `find` to",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (anonymizeProp) {
|
|
197
|
+
report(
|
|
198
|
+
anonymizeProp,
|
|
199
|
+
"anonymize without any subject annotation — no PersonalAnnotations arm accepts anonymize alone",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
// skip: no subject flag on this field — stray lookupable/anonymize already reported above if present
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (subjectProps.length > 1) {
|
|
207
|
+
report(
|
|
208
|
+
subjectProps[0]!.prop,
|
|
209
|
+
`multiple subject annotations on one field (${subjectProps.map((s) => s.name).join(", ")}) — needs a human decision on which subject is correct`,
|
|
210
|
+
);
|
|
211
|
+
// skip: reported above — multiple subject annotations need a human call on which one wins
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const subject = subjectProps[0]!;
|
|
216
|
+
|
|
217
|
+
if (NO_PERSONAL_FACTORIES.has(factoryName)) {
|
|
218
|
+
report(
|
|
219
|
+
subject.prop,
|
|
220
|
+
`${factoryName} has no personal-annotation support — subject flag "${subject.name}" cannot be expressed here`,
|
|
221
|
+
);
|
|
222
|
+
// skip: reported above — this factory has no personal-annotation support to migrate into
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const isTextFind = TEXT_FIND_FACTORIES.has(factoryName);
|
|
226
|
+
const isLongTextFind = LONGTEXT_FIND_FACTORIES.has(factoryName);
|
|
227
|
+
const isNoFind = NO_FIND_FACTORIES.has(factoryName);
|
|
228
|
+
if (!isTextFind && !isLongTextFind && !isNoFind) {
|
|
229
|
+
report(
|
|
230
|
+
subject.prop,
|
|
231
|
+
`unrecognized field factory "${factoryName}" — cannot verify its PersonalAnnotations shape`,
|
|
232
|
+
);
|
|
233
|
+
// skip: reported above — unknown factory shape, cannot verify safely
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let personalInit: string;
|
|
238
|
+
let reasonInit: string | undefined;
|
|
239
|
+
if (subject.name === "pii") {
|
|
240
|
+
if (!isTrueLiteral(subject.prop.getInitializer())) {
|
|
241
|
+
report(subject.prop, "pii is set to a non-`true` value — cannot infer intent");
|
|
242
|
+
// skip: reported above — non-`true` pii value, can't infer intent
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
personalInit = `"self"`;
|
|
246
|
+
} else if (subject.name === "tenantOwned") {
|
|
247
|
+
if (!isTrueLiteral(subject.prop.getInitializer())) {
|
|
248
|
+
report(subject.prop, "tenantOwned is set to a non-`true` value — cannot infer intent");
|
|
249
|
+
// skip: reported above — non-`true` tenantOwned value, can't infer intent
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
personalInit = `"tenant"`;
|
|
253
|
+
} else if (subject.name === "subjectRef") {
|
|
254
|
+
if (!isTrueLiteral(subject.prop.getInitializer())) {
|
|
255
|
+
report(subject.prop, "subjectRef is set to a non-`true` value — cannot infer intent");
|
|
256
|
+
// skip: reported above — non-`true` subjectRef value, can't infer intent
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
personalInit = `"ref"`;
|
|
260
|
+
} else if (subject.name === "allowPlaintext") {
|
|
261
|
+
const init = subject.prop.getInitializer();
|
|
262
|
+
if (!init) {
|
|
263
|
+
report(subject.prop, "allowPlaintext has no value");
|
|
264
|
+
// skip: reported above — allowPlaintext has no value to migrate
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
personalInit = "false";
|
|
268
|
+
reasonInit = Node.isStringLiteral(init)
|
|
269
|
+
? `"${normalizeReasonSlug(init.getLiteralText())}"`
|
|
270
|
+
: init.getText();
|
|
271
|
+
} else {
|
|
272
|
+
const init = subject.prop.getInitializer();
|
|
273
|
+
if (!init || !Node.isObjectLiteralExpression(init)) {
|
|
274
|
+
report(subject.prop, "userOwned value is not an object literal — cannot extract ownerField");
|
|
275
|
+
// skip: reported above — userOwned value isn't an object literal, can't extract ownerField
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const ownerFieldProp = findProp(init, "ownerField");
|
|
279
|
+
if (!ownerFieldProp?.getInitializer()) {
|
|
280
|
+
report(subject.prop, 'userOwned is missing an "ownerField" property');
|
|
281
|
+
// skip: reported above — userOwned is missing its ownerField property
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
personalInit = `{ of: ${ownerFieldProp.getInitializer()!.getText()} }`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const subjectIsRefOrPlaintext =
|
|
288
|
+
subject.name === "subjectRef" || subject.name === "allowPlaintext";
|
|
289
|
+
|
|
290
|
+
if (subjectIsRefOrPlaintext) {
|
|
291
|
+
// These PersonalAnnotations arms carry no `find` field at all.
|
|
292
|
+
if (lookupableProp) {
|
|
293
|
+
report(
|
|
294
|
+
lookupableProp,
|
|
295
|
+
`lookupable combined with personal:${subject.name === "subjectRef" ? '"ref"' : "false"} — findability doesn't apply to this subject, table has no mapping`,
|
|
296
|
+
);
|
|
297
|
+
// skip: reported above — lookupable doesn't apply to this subject, no mapping to migrate
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
applyTransform(obj, [subject.prop], {
|
|
301
|
+
personal: personalInit,
|
|
302
|
+
reason: reasonInit,
|
|
303
|
+
find: undefined,
|
|
304
|
+
});
|
|
305
|
+
counts[subject.name === "subjectRef" ? "ref" : "personal-false"]++;
|
|
306
|
+
// skip: already transformed above — nothing left to check for this subject
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (isNoFind) {
|
|
311
|
+
const stray = lookupableProp ?? searchableProp ?? sensitiveProp;
|
|
312
|
+
if (stray) {
|
|
313
|
+
report(
|
|
314
|
+
stray,
|
|
315
|
+
`${factoryName} has no \`find\` in its PersonalAnnotations — findability flag present on a non-text field`,
|
|
316
|
+
);
|
|
317
|
+
// skip: reported above — findability flag on a non-text field, no valid find to attach
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
applyTransform(obj, [subject.prop], {
|
|
321
|
+
personal: personalInit,
|
|
322
|
+
reason: undefined,
|
|
323
|
+
find: undefined,
|
|
324
|
+
});
|
|
325
|
+
counts["no-find"]++;
|
|
326
|
+
// skip: already transformed above — no-find factory has nothing further to check
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const hasLookupable = !!lookupableProp;
|
|
331
|
+
const hasSearchable = !!searchableProp;
|
|
332
|
+
const hasSensitive = !!sensitiveProp;
|
|
333
|
+
|
|
334
|
+
if (hasSensitive && (hasLookupable || hasSearchable)) {
|
|
335
|
+
report(
|
|
336
|
+
sensitiveProp!,
|
|
337
|
+
'sensitive combined with lookupable/searchable — two find values ("secret" vs "exact"/"fuzzy"), needs a human decision',
|
|
338
|
+
);
|
|
339
|
+
// skip: reported above — sensitive plus lookupable/searchable is an ambiguous find value
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let find: "exact" | "fuzzy" | "none" | "secret";
|
|
344
|
+
if (hasSensitive) find = "secret";
|
|
345
|
+
else if (hasLookupable && hasSearchable) find = "fuzzy";
|
|
346
|
+
else if (hasLookupable) find = "exact";
|
|
347
|
+
else if (hasSearchable) find = "fuzzy";
|
|
348
|
+
else find = "none";
|
|
349
|
+
|
|
350
|
+
if (isLongTextFind && (find === "exact" || find === "fuzzy")) {
|
|
351
|
+
report(
|
|
352
|
+
(lookupableProp ?? searchableProp)!,
|
|
353
|
+
'lookupable/searchable on createLongTextField — only "none"/"secret" are valid find values on longText',
|
|
354
|
+
);
|
|
355
|
+
// skip: reported above — exact/fuzzy find isn't valid on createLongTextField
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// searchable-alone -> "fuzzy" makes expandPersonalAnnotations add
|
|
360
|
+
// `lookupable: true`, which the field didn't carry before — that's a
|
|
361
|
+
// new `_bidx` column (DDL migration), tracked separately for the report.
|
|
362
|
+
if (hasSearchable && !hasLookupable) {
|
|
363
|
+
newLookupableSites.push({
|
|
364
|
+
file: obj.getSourceFile().getFilePath(),
|
|
365
|
+
line: subject.prop.getStartLineNumber(),
|
|
366
|
+
// find: "fuzzy" newly adds lookupable (was searchable-only) — needs a _bidx column migration
|
|
367
|
+
reason: "fuzzy_search_needs_bidx_migration",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const removedProps = [subject.prop, lookupableProp, searchableProp, sensitiveProp].filter(
|
|
372
|
+
(p): p is PropertyAssignment => !!p,
|
|
373
|
+
);
|
|
374
|
+
applyTransform(obj, removedProps, { personal: personalInit, reason: undefined, find });
|
|
375
|
+
counts[find]++;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function applyTransform(
|
|
379
|
+
obj: ObjectLiteralExpression,
|
|
380
|
+
removedProps: readonly PropertyAssignment[],
|
|
381
|
+
next: { personal: string; reason: string | undefined; find: string | undefined },
|
|
382
|
+
): void {
|
|
383
|
+
const properties = obj.getProperties();
|
|
384
|
+
const anchor = removedProps[0]!;
|
|
385
|
+
const anchorIndex = properties.indexOf(anchor);
|
|
386
|
+
const removedSet = new Set<PropertyAssignment>(removedProps);
|
|
387
|
+
let insertIndex = 0;
|
|
388
|
+
for (let i = 0; i < anchorIndex; i++) {
|
|
389
|
+
if (!removedSet.has(properties[i] as PropertyAssignment)) insertIndex++;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const p of removedProps) p.remove();
|
|
393
|
+
|
|
394
|
+
const newProps: { name: string; initializer: string }[] = [
|
|
395
|
+
{ name: "personal", initializer: next.personal },
|
|
396
|
+
];
|
|
397
|
+
if (next.find) newProps.push({ name: "find", initializer: `"${next.find}"` });
|
|
398
|
+
if (next.reason !== undefined) newProps.push({ name: "reason", initializer: next.reason });
|
|
399
|
+
|
|
400
|
+
obj.insertPropertyAssignments(insertIndex, newProps);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function findTargetFiles(rootDir: string): string[] {
|
|
404
|
+
const glob = new Glob("**/*.{ts,tsx}");
|
|
405
|
+
// engine/factories.ts constructs raw ResolvedPiiFlags literals as the
|
|
406
|
+
// *implementation* of expandPersonalAnnotations (personal -> flags) —
|
|
407
|
+
// not an authored override, so it's not a migration target.
|
|
408
|
+
const EXCLUDE = ["/node_modules/", "/dist/", "/build/", "/engine/factories.ts"];
|
|
409
|
+
const files: string[] = [];
|
|
410
|
+
for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
|
|
411
|
+
const abs = resolve(rootDir, file);
|
|
412
|
+
if (EXCLUDE.some((p) => abs.includes(p))) continue;
|
|
413
|
+
files.push(abs);
|
|
414
|
+
}
|
|
415
|
+
return files.sort();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function main(): Promise<void> {
|
|
419
|
+
const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
420
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
421
|
+
const rootDir = resolve(positional[0] ?? process.cwd());
|
|
422
|
+
|
|
423
|
+
const files = findTargetFiles(rootDir);
|
|
424
|
+
const project = new Project({
|
|
425
|
+
skipAddingFilesFromTsConfig: true,
|
|
426
|
+
skipFileDependencyResolution: true,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let touchedFiles = 0;
|
|
430
|
+
for (const file of files) {
|
|
431
|
+
const sourceFile = project.addSourceFileAtPath(file);
|
|
432
|
+
const calls = collectFieldCalls(sourceFile);
|
|
433
|
+
const handled = new Set(calls.map((c) => c.objArg));
|
|
434
|
+
reportRawSubjectLiterals(sourceFile, handled);
|
|
435
|
+
|
|
436
|
+
const countsBefore = { ...counts };
|
|
437
|
+
for (const { name, objArg } of calls) processObjectLiteral(objArg, name);
|
|
438
|
+
const changed = (Object.keys(counts) as FindBucket[]).some(
|
|
439
|
+
(k) => counts[k] !== countsBefore[k],
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (changed) {
|
|
443
|
+
touchedFiles++;
|
|
444
|
+
if (!dryRun) sourceFile.saveSync();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
console.log(`\nScanned ${files.length} files under ${rootDir}${dryRun ? " (dry-run)" : ""}.`);
|
|
449
|
+
console.log(`Touched ${touchedFiles} files.\n`);
|
|
450
|
+
console.log("Transformed, by find/personal bucket:");
|
|
451
|
+
for (const [bucket, n] of Object.entries(counts)) {
|
|
452
|
+
if (n > 0) console.log(` ${bucket}: ${n}`);
|
|
453
|
+
}
|
|
454
|
+
console.log(`\nNewly gains lookupable (needs a _bidx migration): ${newLookupableSites.length}`);
|
|
455
|
+
for (const r of newLookupableSites) {
|
|
456
|
+
console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
|
|
457
|
+
}
|
|
458
|
+
console.log(`\nReported (not transformed): ${reports.length}`);
|
|
459
|
+
for (const r of reports) {
|
|
460
|
+
console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await main();
|
package/src/upgrade-cli.ts
CHANGED
|
@@ -164,19 +164,39 @@ export function findFeaturesDirs(cwd: string): string[] {
|
|
|
164
164
|
return dirs;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// Consumer-facing codemod scripts ship inside the published
|
|
168
|
+
// @cosmicdrift/kumiko-framework package (src/scripts/codemod), not at the
|
|
169
|
+
// git repo root — only the installed package path exists in a consumer's
|
|
170
|
+
// node_modules after a plain npm/bun install (fw#2301).
|
|
171
|
+
export function findCodemodScriptsRoot(repoRoot: string): string | null {
|
|
172
|
+
const local = join(repoRoot, "packages/framework/src");
|
|
173
|
+
if (existsSync(join(local, CODEMOD_SUBDIR))) return local;
|
|
174
|
+
|
|
175
|
+
let dir = repoRoot;
|
|
176
|
+
for (let i = 0; i < 10; i++) {
|
|
177
|
+
const nmSrc = join(dir, "node_modules/@cosmicdrift/kumiko-framework/src");
|
|
178
|
+
if (existsSync(join(nmSrc, CODEMOD_SUBDIR))) return nmSrc;
|
|
179
|
+
const parent = join(dir, "..");
|
|
180
|
+
if (parent === dir) break;
|
|
181
|
+
dir = parent;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
167
187
|
// Resolves a changes.json `codemod` field to an absolute script path,
|
|
168
188
|
// refusing anything that would escape scripts/codemod/ (path traversal,
|
|
169
189
|
// absolute paths, symlinks pointing outward) or that isn't a real .ts file.
|
|
170
190
|
export function resolveCodemodScript(
|
|
171
|
-
|
|
191
|
+
codemodScriptsRoot: string,
|
|
172
192
|
codemodField: string | undefined,
|
|
173
193
|
): string | null {
|
|
174
194
|
if (!codemodField) return null;
|
|
175
195
|
if (codemodField.includes("\0") || codemodField.startsWith("/") || !codemodField.endsWith(".ts"))
|
|
176
196
|
return null;
|
|
177
197
|
|
|
178
|
-
const scriptsRoot = join(
|
|
179
|
-
const resolved = join(
|
|
198
|
+
const scriptsRoot = join(codemodScriptsRoot, CODEMOD_SUBDIR);
|
|
199
|
+
const resolved = join(codemodScriptsRoot, codemodField);
|
|
180
200
|
const rel = relative(scriptsRoot, resolved);
|
|
181
201
|
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
182
202
|
if (!existsSync(resolved)) return null;
|
|
@@ -279,9 +299,12 @@ async function applyCodemods(
|
|
|
279
299
|
return 0;
|
|
280
300
|
}
|
|
281
301
|
|
|
302
|
+
const codemodScriptsRoot = findCodemodScriptsRoot(repoRoot);
|
|
282
303
|
const ran: UpgradeMarkerCodemod[] = [];
|
|
283
304
|
for (const e of codemodEntries) {
|
|
284
|
-
const scriptPath =
|
|
305
|
+
const scriptPath = codemodScriptsRoot
|
|
306
|
+
? resolveCodemodScript(codemodScriptsRoot, e.codemod)
|
|
307
|
+
: null;
|
|
285
308
|
if (!scriptPath) {
|
|
286
309
|
out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
|
|
287
310
|
return 1;
|