@cosmicdrift/kumiko-framework 0.336.1 → 0.337.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 (96) hide show
  1. package/dist/api/auth-middleware.js +43 -19
  2. package/dist/api/auth-routes.d.ts +1 -0
  3. package/dist/api/auth-routes.js +20 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +4 -5
  6. package/dist/api/server.js +3 -3
  7. package/dist/api/sse-route.js +14 -1
  8. package/dist/bun-db/query.js +6 -4
  9. package/dist/crypto/kek-source.js +1 -0
  10. package/dist/db/blind-index-cleanup.js +1 -1
  11. package/dist/db/event-store-executor-read.js +15 -4
  12. package/dist/db/queries/event-consumer.d.ts +2 -2
  13. package/dist/db/queries/event-consumer.js +5 -4
  14. package/dist/db/queries/event-store.js +6 -6
  15. package/dist/db/queries/projection-rebuild.js +4 -4
  16. package/dist/db/queries/seed-context.js +6 -6
  17. package/dist/derivatives/derivatives-context.js +5 -2
  18. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  19. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  20. package/dist/engine/boot-validator/screens.js +12 -0
  21. package/dist/engine/build-config-feature-schema.js +4 -0
  22. package/dist/engine/define-feature.js +1 -0
  23. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  24. package/dist/engine/feature-ast/extractors/index.js +1 -1
  25. package/dist/engine/feature-ast/extractors/jobs-routes.js +6 -2
  26. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -1
  27. package/dist/engine/feature-ast/extractors/round3.js +11 -0
  28. package/dist/engine/feature-ast/extractors/shared.js +1 -3
  29. package/dist/engine/feature-ast/index.d.ts +1 -1
  30. package/dist/engine/feature-ast/parse.js +3 -1
  31. package/dist/engine/feature-ast/patch.d.ts +3 -0
  32. package/dist/engine/feature-ast/patch.js +8 -11
  33. package/dist/engine/feature-ast/patcher.d.ts +6 -1
  34. package/dist/engine/feature-ast/patcher.js +3 -0
  35. package/dist/engine/feature-ast/pattern-change-schema.d.ts +590 -1
  36. package/dist/engine/feature-ast/pattern-change-schema.js +17 -2
  37. package/dist/engine/feature-ast/patterns.d.ts +8 -2
  38. package/dist/engine/feature-ast/patterns.js +1 -0
  39. package/dist/engine/feature-ast/render.js +9 -0
  40. package/dist/engine/feature-builder-state.d.ts +2 -1
  41. package/dist/engine/feature-builder-state.js +1 -0
  42. package/dist/engine/feature-config-events-jobs.d.ts +4 -1
  43. package/dist/engine/feature-config-events-jobs.js +20 -0
  44. package/dist/engine/feature-ui-extensions.js +15 -21
  45. package/dist/engine/index.d.ts +1 -1
  46. package/dist/engine/pattern-library/library.js +2 -1
  47. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  48. package/dist/engine/pattern-library/static-schemas.js +47 -4
  49. package/dist/engine/registry-facade.js +7 -0
  50. package/dist/engine/registry-ingest.js +7 -0
  51. package/dist/engine/registry-state.d.ts +2 -1
  52. package/dist/engine/registry-state.js +1 -0
  53. package/dist/engine/registry-validate.d.ts +1 -1
  54. package/dist/engine/registry-validate.js +39 -17
  55. package/dist/engine/registry.js +2 -2
  56. package/dist/engine/types/index.d.ts +2 -2
  57. package/dist/engine/types/index.js +1 -1
  58. package/dist/engine/types/screen.d.ts +1 -1
  59. package/dist/engine/types/screen.js +1 -1
  60. package/dist/errors/classes.d.ts +5 -0
  61. package/dist/errors/classes.js +15 -0
  62. package/dist/errors/index.d.ts +1 -1
  63. package/dist/errors/index.js +1 -1
  64. package/dist/i18n/required-surface-keys.js +3 -5
  65. package/dist/migrations/pending-rebuilds.d.ts +2 -0
  66. package/dist/migrations/pending-rebuilds.js +3 -0
  67. package/dist/pipeline/dispatch-query.js +3 -5
  68. package/dist/pipeline/dispatch-stream.js +2 -4
  69. package/dist/pipeline/dispatch-write.js +2 -4
  70. package/dist/pipeline/event-dispatcher-delivery.d.ts +0 -1
  71. package/dist/pipeline/event-dispatcher-delivery.js +1 -8
  72. package/dist/pipeline/event-dispatcher.js +16 -25
  73. package/dist/pipeline/handler-access-error.d.ts +3 -0
  74. package/dist/pipeline/handler-access-error.js +17 -0
  75. package/dist/pipeline/irreversible-operation-gate.js +9 -3
  76. package/dist/pipeline/member-read-only-transaction.js +8 -3
  77. package/dist/pipeline/system-hooks.js +7 -1
  78. package/dist/pipeline/system-identity-switch.js +1 -1
  79. package/dist/search/meilisearch-adapter.js +0 -2
  80. package/dist/stack/push-entity-projection-tables.js +5 -3
  81. package/dist/stack/redis.d.ts +1 -0
  82. package/dist/stack/redis.js +11 -2
  83. package/dist/stack/table-helpers.d.ts +3 -1
  84. package/dist/stack/table-helpers.js +9 -7
  85. package/dist/stack/test-stack.js +1 -7
  86. package/dist/testing/handler-context.js +3 -6
  87. package/dist/ui-types/index.d.ts +1 -1
  88. package/dist/ui-types/index.js +1 -1
  89. package/dist/upgrade-cli.js +29 -2
  90. package/package.json +4 -4
  91. package/src/changes.json +92 -0
  92. package/src/scripts/codemod/collect-source-files.ts +48 -0
  93. package/src/scripts/codemod/migrate-cross-tenant.ts +3 -23
  94. package/src/scripts/codemod/migrate-db-raw.ts +6 -23
  95. package/src/scripts/codemod/migrate-open-to-all.ts +4 -24
  96. package/src/scripts/codemod/unprocessable-error-details-reason.ts +47 -3
@@ -109,10 +109,12 @@ export function findPackageChangelogFiles(cwd) {
109
109
  for (const name of pkgNames) {
110
110
  if (seenNames.has(name))
111
111
  continue;
112
+ // Claimed by the nearest scope even without a changelog, so a farther
113
+ // node_modules can't contribute another version of the same package.
114
+ seenNames.add(name);
112
115
  const changelogPath = join(scopeDir, name, "src", "changes.json");
113
116
  if (!existsSync(changelogPath))
114
117
  continue;
115
- seenNames.add(name);
116
118
  const realPath = realpathSync(changelogPath);
117
119
  if (seenRealPaths.has(realPath))
118
120
  continue;
@@ -305,9 +307,30 @@ function logManualMarkerOutcome(out, manualEntries, markerVersion) {
305
307
  : ` ⚠ Marker moved past the manual change(s) above to ${markerVersion}; the upgrade guard no longer lists them, so make sure they are migrated.`);
306
308
  }
307
309
  }
310
+ // Records the codemods that already wrote files before a later one failed, so
311
+ // a re-run resumes after them instead of replaying non-idempotent codemods.
312
+ function writePartialMarker(out, targetDir, pending, manualEntries, failedVersion, ran) {
313
+ // skip: no codemod wrote files before the failure, nothing to resume after
314
+ if (ran.length === 0)
315
+ return;
316
+ const doneBeforeFailure = pending.filter((e) => compareVersions(e.version, failedVersion) < 0);
317
+ const version = markerVersionForPending(doneBeforeFailure, manualEntries, "");
318
+ // skip: no pending entry completed before the failure, so no marker version to record
319
+ if (version === "")
320
+ return;
321
+ const pendingManual = manualEntries.map((e) => ({ version: e.version, title: e.title }));
322
+ writeUpgradeMarker(targetDir, {
323
+ version,
324
+ appliedAt: Temporal.Now.instant().toString(),
325
+ codemods: ran,
326
+ ...(pendingManual.length > 0 && { pendingManual }),
327
+ });
328
+ out.err(` ⚠ ${ran.length} codemod(s) already applied; wrote marker at ${version} so a re-run resumes at the failed one.`);
329
+ }
308
330
  // Runs every pending breaking entry's codemod, oldest version first (so a
309
331
  // later codemod can assume an earlier one already ran). Stops on the first
310
- // failure — no partial marker. Writes the marker whenever dryRun is false —
332
+ // failure; codemods that already ran are kept in a partial marker. Writes the
333
+ // marker whenever dryRun is false —
311
334
  // even with zero pending entries, so an already-current app still gets a
312
335
  // bootstrap marker recording its installed version (fw#2299).
313
336
  // kumiko-lint-ignore complexity-budget sequential codemod runner with zero-pending bootstrap marker
@@ -364,6 +387,8 @@ markerVersion) {
364
387
  const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
365
388
  if (!scriptPath) {
366
389
  out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
390
+ if (!dryRun)
391
+ writePartialMarker(out, targetDir, pending, manualEntries, e.version, ran);
367
392
  return 1;
368
393
  }
369
394
  out.log(` → ${e.version} · running ${e.codemod}${dryRun ? " (dry-run)" : ""}`);
@@ -372,6 +397,8 @@ markerVersion) {
372
397
  out.log(result.output);
373
398
  if (!result.ok) {
374
399
  out.err(` ✗ ${e.version} · ${e.codemod} failed`);
400
+ if (!dryRun)
401
+ writePartialMarker(out, targetDir, pending, manualEntries, e.version, ran);
375
402
  return 1;
376
403
  }
377
404
  ran.push({ version: e.version, codemod: e.codemod, title: e.title });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.336.1",
3
+ "version": "0.337.1",
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>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.336.1",
210
- "@cosmicdrift/kumiko-types": "0.336.1",
209
+ "@cosmicdrift/kumiko-http": "0.337.1",
210
+ "@cosmicdrift/kumiko-types": "0.337.1",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.336.1",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.337.1",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,96 @@
1
1
  [
2
+ {
3
+ "version": "0.337.1",
4
+ "type": "improvement",
5
+ "title": "Designer reads and creates r.secretNamespace, edits writeRoles on secrets",
6
+ "detail": "The Designer source model now parses `r.secretNamespace(...)` as its own pattern kind `secretNamespace` instead of falling back to `unknown`, renders it back and can add it through the pattern library. A Zod `nameSchema` or an options identifier is kept verbatim. The `r.secret` form gains a `writeRoles` list and no longer offers the `system` scope, which the secret type never allowed."
7
+ },
8
+ {
9
+ "version": "0.337.0",
10
+ "type": "fix",
11
+ "title": "variant() ignores caller-supplied resolvedOverlays",
12
+ "detail": "`derivatives.variant()` now discards a caller-supplied `resolvedOverlays`, so a literal overlay payload can no longer bypass the overlay resolver. The guarded admin seed throws instead of silently picking by id when a user row's `insertedAt` is not an instant."
13
+ },
14
+ {
15
+ "version": "0.337.0",
16
+ "type": "breaking",
17
+ "title": "Anonymous callers on protected handlers get 401 unauthenticated, and a stale auth cookie no longer blocks public handlers",
18
+ "migration": "Tests or clients that expect 403 / access_denied for an anonymous caller (no session, the anonymousAccess user, or a JWT carrying only the anonymous role) on a role-gated or openToAll handler now get 401 / unauthenticated; update those assertions. Signed-in callers without the role still get 403 access_denied. With anonymousAccess wired, a request whose auth cookie fails verification or whose session is no longer live continues as anonymous outside /api/auth/* (public handlers answer 200, protected ones 401 unauthenticated) instead of failing with 401 invalid_token / session_invalid; bearer tokens and /api/auth/* keep the explicit 401."
19
+ },
20
+ {
21
+ "version": "0.337.0",
22
+ "type": "fix",
23
+ "title": "Anonymous SSE signals no longer carry row id or version",
24
+ "detail": "An anonymous /api/sse connection gets every row signal of a declared liveEntities entity, including rows the anonymous query hides. The frame now carries only aggregateType, eventType and createdAt, so a public viewer can no longer see which hidden rows exist or change; signed-in connections keep id and version. LiveEvent.data.id and version are optional in kumiko-renderer accordingly."
25
+ },
26
+ {
27
+ "version": "0.337.0",
28
+ "type": "breaking",
29
+ "title": "buildServer refuses partially wired session callbacks",
30
+ "migration": "If you call buildServer with auth.sessionCreator or auth.sessionRevoker, also wire the other two of sessionCreator, sessionRevoker and sessionChecker (the sessions feature's sessionStore provides all three; runProdApp and runDevApp already do this). Without a sessionChecker a logout never invalidated the JWT. A sessionChecker alone stays allowed."
31
+ },
32
+ {
33
+ "version": "0.337.0",
34
+ "type": "fix",
35
+ "title": "Job triggers accept short handler and event names",
36
+ "detail": "r.job trigger.on now qualifies a short name against the job's own feature (write handler, then query handler, then defineEvent), like notification triggers, and the boot error lists every name it tried."
37
+ },
38
+ {
39
+ "version": "0.337.0",
40
+ "type": "improvement",
41
+ "title": "Boot rejects a collapsed relatedList group without a header; expandableRow groupBy keys are required i18n keys",
42
+ "detail": "Boot fails when `groupBy.collapsedWhen` names a group that has neither `label` nor a `labels` entry, because its rows could never be opened. The required surface keys now include `groupBy.label` and `groupBy.labels` of an entityList `expandableRow`, as they already did for projectionDetail relatedList sections.",
43
+ "migration": "Add translations for expandableRow `groupBy` header keys if the i18n check reports them missing."
44
+ },
45
+ {
46
+ "version": "0.337.0",
47
+ "type": "fix",
48
+ "title": "json format falls back to text, IPv4-first egress pin, structural claim comparison for identity switch",
49
+ "detail": "The `json` format no longer throws on BigInt or circular values and falls back to text. `resolvePublicHost` prefers an IPv4 address on dual-stack hosts. Identity-switch claim comparison no longer treats structurally equal object claims in a different key order as a different identity."
50
+ },
51
+ {
52
+ "version": "0.337.0",
53
+ "type": "fix",
54
+ "title": "reference search and sort skip soft-deleted targets, blind-index sweep resolves tables in public, key-manager retry releases 5xx bodies",
55
+ "detail": "List search and sort by a reference field now ignore soft-deleted target rows: a deleted customer's name no longer matches a reference search and no longer decides the position in a label sort. The blind-index sweep checks table existence in the `public` schema instead of relying on the session `search_path`. The Scaleway key-manager retry cancels the response body of a retried 5xx instead of leaving it open."
56
+ },
57
+ {
58
+ "version": "0.337.0",
59
+ "type": "fix",
60
+ "title": "boot validator rejects redirect on list row actions, httpRoute with non-literal anonymous is a parse error, local const shadows same-named import in feature-ast, r.nav strips undefined properties",
61
+ "detail": "The boot validator now rejects a `redirect` on `entityList`, `projectionList` and `relatedList` row actions, where it was silently ignored. The feature-ast parser reports an `httpRoute` whose `anonymous` is a non-literal expression as a parse error instead of rewriting it to `false` on round-trip, and a local non-literal `const` no longer resolves to a same-named import. `r.nav()` strips explicitly-`undefined` properties for every registration path."
62
+ },
63
+ {
64
+ "version": "0.337.0",
65
+ "type": "fix",
66
+ "title": "isRedisSseBroker narrows on a kind discriminant, tenant-lifecycle gate cache is bounded",
67
+ "detail": "`isRedisSseBroker` now narrows on a `kind: \"redis\"` discriminant that `createRedisSseBroker` sets, so an app-owned broker that happens to expose `close()` is no longer mistaken for the Redis one. The tenant-lifecycle gate cache is capped at 5000 entries, so anonymous requests with random `X-Tenant` ids can no longer grow it without bound."
68
+ },
69
+ {
70
+ "version": "0.337.0",
71
+ "type": "fix",
72
+ "title": "search extensions never see sensitive row fields on named events, streams cannot hard-delete (gate message now says so), codemod walkers and namespace imports hardened",
73
+ "detail": "The search consumer no longer hands `sensitive` columns of the projection row to `searchPayloadExtension` contributors when a named domain event triggers the re-index. A `skipApplyErrors` rebuild option passed for a multi-stream projection is now logged as ignored instead of dropped silently. The irreversible-operation gate error for stream entry handlers says that streams cannot declare an agent risk (streams always resolve `mid`, so a hard delete or `forget` inside a stream is denied; dispatch a risk `high` write handler directly). The `migrate-open-to-all` codemod skips symlinks and `dist`/`build`/`.next`/`.git`, and the `unprocessable-error-details-reason` codemod follows namespace imports and reports member-access callees it cannot resolve."
74
+ },
75
+ {
76
+ "version": "0.337.0",
77
+ "type": "fix",
78
+ "title": "search remove no longer creates indexes, upgrade --apply keeps a partial marker on codemod failure, test stack syncs indexes and cleans queue keys, codemod walkers skip symlinks",
79
+ "detail": "Meilisearch `remove` and `removeBatch` no longer configure (and thereby implicitly create) the tenant index before deleting. `kumiko-upgrade --apply` writes a partial upgrade marker when a later codemod fails, so a re-run resumes at the failed codemod instead of replaying the ones that already wrote files. The changelog lookup claims a package name in the nearest `node_modules` even when it has no `changes.json`, instead of listing a farther version's changelog. The test stack syncs missing indexes (including unique) on a persistent dev DB and removes the BullMQ keys of its derived per-stack queues on cleanup. `bridgeStub` defaults to the real anonymous identity. The `migrate-cross-tenant` and `migrate-db-raw` codemods share one walker with `migrate-open-to-all` that skips symlinks and `dist`/`build`/`.next`/`.git`."
80
+ },
81
+ {
82
+ "version": "0.337.0",
83
+ "type": "fix",
84
+ "title": "Removed unreachable PAT rate-limit guard from the httpRoute chain",
85
+ "detail": "The httpRoute guard chain no longer includes the PAT rate-limit guard. PATs are rejected on httpRoutes before it could count, so behavior is unchanged."
86
+ },
87
+ {
88
+ "version": "0.337.0",
89
+ "type": "improvement",
90
+ "title": "r.secretNamespace declares a family of runtime-named secret keys",
91
+ "detail": "`r.secretNamespace(name, { label, scope: \"tenant\", writeRoles?, nameSchema? })` declares the prefix `<feature>:<name>.` (kebab-cased) and returns `{ prefix, keyFor(name) }`. `secrets:write:set` and `secrets:write:delete` accept a key under that prefix when the suffix is non-empty and passes `nameSchema`; `writeRoles` applies to every key in the namespace. Namespaces are kept out of `getAllSecretKeys` and the generated secrets screen. `Registry.findSecretNamespace(key)` resolves the namespace of a key. step-dispatcher declares `webhook-auth`, so webhook auth secrets (`step-dispatcher:webhook-auth.<name>`) stay settable through the API.",
92
+ "migration": "No code change needed. A feature that stores secrets under a runtime-chosen suffix declares a namespace instead of one `r.secret` per key."
93
+ },
2
94
  {
3
95
  "version": "0.336.1",
4
96
  "type": "fix",
@@ -0,0 +1,48 @@
1
+ import { lstatSync, readdirSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ const BUILD_AND_VENDOR_DIR_NAMES: readonly string[] = [
5
+ "node_modules",
6
+ "dist",
7
+ "build",
8
+ ".next",
9
+ ".git",
10
+ ];
11
+
12
+ export interface CollectSourceFilesOptions {
13
+ readonly isMigratableFile: (name: string) => boolean;
14
+ readonly extraSkippedDirNames?: readonly string[];
15
+ }
16
+
17
+ export function collectSourceFiles(
18
+ paths: readonly string[],
19
+ options: CollectSourceFilesOptions,
20
+ ): string[] {
21
+ const skippedDirNames = new Set([
22
+ ...BUILD_AND_VENDOR_DIR_NAMES,
23
+ ...(options.extraSkippedDirNames ?? []),
24
+ ]);
25
+
26
+ function walkDir(dir: string, out: string[]): void {
27
+ for (const name of readdirSync(dir)) {
28
+ if (skippedDirNames.has(name)) continue;
29
+ const full = join(dir, name);
30
+ const stat = lstatSync(full);
31
+ // Symlinks can point back at a parent directory (workspace links) and loop forever.
32
+ if (stat.isSymbolicLink()) continue;
33
+ if (stat.isDirectory()) {
34
+ walkDir(full, out);
35
+ continue;
36
+ }
37
+ if (options.isMigratableFile(name)) out.push(full);
38
+ }
39
+ }
40
+
41
+ const files: string[] = [];
42
+ for (const p of paths) {
43
+ const abs = resolve(p);
44
+ if (statSync(abs).isDirectory()) walkDir(abs, files);
45
+ else if (options.isMigratableFile(abs)) files.push(abs);
46
+ }
47
+ return files;
48
+ }
@@ -6,8 +6,7 @@
6
6
  // Usage:
7
7
  // bun scripts/codemod/migrate-cross-tenant.ts [--dry-run] <path...>
8
8
 
9
- import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
- import { join, resolve } from "node:path";
9
+ import { readFileSync, writeFileSync } from "node:fs";
11
10
  import {
12
11
  type CallExpression,
13
12
  Node,
@@ -16,6 +15,7 @@ import {
16
15
  type PropertyAssignment,
17
16
  SyntaxKind,
18
17
  } from "ts-morph";
18
+ import { collectSourceFiles } from "./collect-source-files.js";
19
19
 
20
20
  export interface Rewrite {
21
21
  readonly file: string;
@@ -264,28 +264,8 @@ function isMigratableFile(name: string): boolean {
264
264
  return /\.(ts|tsx)$/.test(name);
265
265
  }
266
266
 
267
- function walkDir(dir: string, out: string[]): void {
268
- for (const name of readdirSync(dir)) {
269
- if (name === "node_modules") continue;
270
- const full = join(dir, name);
271
- const stat = statSync(full);
272
- if (stat.isDirectory()) {
273
- walkDir(full, out);
274
- continue;
275
- }
276
- if (isMigratableFile(name)) out.push(full);
277
- }
278
- }
279
-
280
267
  function collectFiles(paths: string[]): string[] {
281
- const files: string[] = [];
282
- for (const p of paths) {
283
- const abs = resolve(p);
284
- const stat = statSync(abs);
285
- if (stat.isDirectory()) walkDir(abs, files);
286
- else if (isMigratableFile(abs)) files.push(abs);
287
- }
288
- return files;
268
+ return collectSourceFiles(paths, { isMigratableFile });
289
269
  }
290
270
 
291
271
  async function main(): Promise<void> {
@@ -7,9 +7,9 @@
7
7
  // Usage:
8
8
  // bun scripts/codemod/migrate-db-raw.ts [--dry-run] [--global-tables a,b] <path...>
9
9
 
10
- import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
- import { join, resolve } from "node:path";
10
+ import { readFileSync, writeFileSync } from "node:fs";
12
11
  import { Node, Project, type SourceFile, SyntaxKind, VariableDeclarationKind } from "ts-morph";
12
+ import { collectSourceFiles } from "./collect-source-files.js";
13
13
 
14
14
  const DEFAULT_GLOBAL_TABLES: ReadonlySet<string> = new Set([
15
15
  "userTable",
@@ -334,28 +334,11 @@ function isMigratableFile(name: string): boolean {
334
334
  return true;
335
335
  }
336
336
 
337
- function walkDir(dir: string, out: string[]): void {
338
- for (const name of readdirSync(dir)) {
339
- if (name === "node_modules" || name === "__tests__") continue;
340
- const full = join(dir, name);
341
- const stat = statSync(full);
342
- if (stat.isDirectory()) {
343
- walkDir(full, out);
344
- continue;
345
- }
346
- if (isMigratableFile(name)) out.push(full);
347
- }
348
- }
349
-
350
337
  function collectFiles(paths: string[]): string[] {
351
- const files: string[] = [];
352
- for (const p of paths) {
353
- const abs = resolve(p);
354
- const stat = statSync(abs);
355
- if (stat.isDirectory()) walkDir(abs, files);
356
- else if (isMigratableFile(abs)) files.push(abs);
357
- }
358
- return files;
338
+ return collectSourceFiles(paths, {
339
+ isMigratableFile,
340
+ extraSkippedDirNames: ["__tests__"],
341
+ });
359
342
  }
360
343
 
361
344
  async function main(): Promise<void> {
@@ -6,8 +6,7 @@
6
6
  // Usage:
7
7
  // bun scripts/codemod/migrate-open-to-all.ts [--dry-run] [--test-reason "<text>"] <path...>
8
8
 
9
- import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
- import { join, resolve } from "node:path";
9
+ import { readFileSync, writeFileSync } from "node:fs";
11
10
  import {
12
11
  Node,
13
12
  type ObjectLiteralExpression,
@@ -15,6 +14,7 @@ import {
15
14
  type PropertyAssignment,
16
15
  SyntaxKind,
17
16
  } from "ts-morph";
17
+ import { collectSourceFiles } from "./collect-source-files.js";
18
18
 
19
19
  const DEFAULT_TEST_REASON = "test handler callable by any signed-in test user";
20
20
 
@@ -164,28 +164,8 @@ function isMigratableFile(name: string): boolean {
164
164
  return /\.(ts|tsx)$/.test(name);
165
165
  }
166
166
 
167
- function walkDir(dir: string, out: string[]): void {
168
- for (const name of readdirSync(dir)) {
169
- if (name === "node_modules") continue;
170
- const full = join(dir, name);
171
- const stat = statSync(full);
172
- if (stat.isDirectory()) {
173
- walkDir(full, out);
174
- continue;
175
- }
176
- if (isMigratableFile(name)) out.push(full);
177
- }
178
- }
179
-
180
- function collectFiles(paths: string[]): string[] {
181
- const files: string[] = [];
182
- for (const p of paths) {
183
- const abs = resolve(p);
184
- const stat = statSync(abs);
185
- if (stat.isDirectory()) walkDir(abs, files);
186
- else if (isMigratableFile(abs)) files.push(abs);
187
- }
188
- return files;
167
+ export function collectFiles(paths: string[]): string[] {
168
+ return collectSourceFiles(paths, { isMigratableFile });
189
169
  }
190
170
 
191
171
  // Remaining manual sites still fail boot validation, so upgrade-cli must not
@@ -51,14 +51,20 @@ function findTargetFiles(rootDir: string): string[] {
51
51
  return files.sort();
52
52
  }
53
53
 
54
+ const NAMESPACE_IMPORT = "*";
55
+
54
56
  type ImportedBinding = { readonly importedName: string; readonly moduleSpecifier: string };
55
57
 
56
- /** Local-name -> where it's imported from, for every named value import in the file. */
58
+ /** Local-name -> where it's imported from, for every named or namespace value import in the file. */
57
59
  function collectImportedNames(sourceFile: SourceFile): Map<string, ImportedBinding> {
58
60
  const bindings = new Map<string, ImportedBinding>();
59
61
  for (const imp of sourceFile.getImportDeclarations()) {
60
62
  if (imp.isTypeOnly()) continue;
61
63
  const moduleSpecifier = imp.getModuleSpecifierValue();
64
+ const namespaceImport = imp.getNamespaceImport();
65
+ if (namespaceImport) {
66
+ bindings.set(namespaceImport.getText(), { importedName: NAMESPACE_IMPORT, moduleSpecifier });
67
+ }
62
68
  for (const spec of imp.getNamedImports()) {
63
69
  if (spec.isTypeOnly()) continue;
64
70
  const importedName = spec.getName();
@@ -264,6 +270,17 @@ const FAIL_UNPROCESSABLE_CALL: MigratableCall = {
264
270
  process: processFailUnprocessableCall,
265
271
  };
266
272
 
273
+ function isKumikoNamespaceReceiver(
274
+ receiver: Node,
275
+ importedNames: ReadonlyMap<string, ImportedBinding>,
276
+ ): boolean {
277
+ if (!Node.isIdentifier(receiver)) return false;
278
+ const binding = importedNames.get(receiver.getText());
279
+ return (
280
+ binding?.importedName === NAMESPACE_IMPORT && KUMIKO_MODULE_RE.test(binding.moduleSpecifier)
281
+ );
282
+ }
283
+
267
284
  function migrateCallSite(
268
285
  sourceFile: SourceFile,
269
286
  importedNames: ReadonlyMap<string, ImportedBinding>,
@@ -272,11 +289,28 @@ function migrateCallSite(
272
289
  skips: SkipEntry[],
273
290
  ): boolean {
274
291
  const callee = site.getExpression();
275
- if (!Node.isIdentifier(callee)) return false;
276
- const localName = callee.getText();
277
292
  const line = site.getStartLineNumber();
278
293
  const file = sourceFile.getFilePath();
279
294
 
295
+ if (!Node.isIdentifier(callee)) {
296
+ const unresolvedCallee = callee.getText();
297
+ const resolvedViaNamespace =
298
+ Node.isPropertyAccessExpression(callee) &&
299
+ callee.getName() === target.exportName &&
300
+ isKumikoNamespaceReceiver(callee.getExpression(), importedNames);
301
+ if (resolvedViaNamespace) return processCall(site, target, skips, file, line);
302
+ if (unresolvedCallee.endsWith(target.exportName)) {
303
+ skips.push({
304
+ file,
305
+ line,
306
+ note: `${target.exportName} is called through "${unresolvedCallee}", which is not a @cosmicdrift/kumiko-* namespace import this codemod can resolve`,
307
+ kind: "manual",
308
+ });
309
+ }
310
+ return false;
311
+ }
312
+ const localName = callee.getText();
313
+
280
314
  const binding = importedNames.get(localName);
281
315
  if (binding?.importedName !== target.exportName) {
282
316
  if (localName !== target.exportName) return false;
@@ -296,6 +330,16 @@ function migrateCallSite(
296
330
  return false;
297
331
  }
298
332
 
333
+ return processCall(site, target, skips, file, line);
334
+ }
335
+
336
+ function processCall(
337
+ site: Node & { getArguments(): Node[] },
338
+ target: MigratableCall,
339
+ skips: SkipEntry[],
340
+ file: string,
341
+ line: number,
342
+ ): boolean {
299
343
  const outcome = target.process(site.getArguments(), (note, kind) =>
300
344
  skips.push({ file, line, note, kind }),
301
345
  );