@cosmicdrift/kumiko-framework 0.367.0 → 0.369.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.
Files changed (240) hide show
  1. package/AGENTS.md +1 -7
  2. package/README.md +1 -1
  3. package/agents/examples/access-control.md +2 -2
  4. package/agents/examples/anonymous-access-multitenant.md +2 -2
  5. package/agents/examples/anonymous-access.md +3 -3
  6. package/agents/examples/apex-landing.md +1 -1
  7. package/agents/examples/apex-surface-auth.md +6 -7
  8. package/agents/examples/api-worker-split.md +7 -8
  9. package/agents/examples/auth-claims.md +2 -1
  10. package/agents/examples/auth-foundation-providers.md +3 -3
  11. package/agents/examples/auth-mfa.md +3 -4
  12. package/agents/examples/basic-entity.md +3 -4
  13. package/agents/examples/chat-channels.md +4 -4
  14. package/agents/examples/compliance-profiles-demo.md +2 -3
  15. package/agents/examples/cross-feature-events.md +4 -4
  16. package/agents/examples/crypto-shredding-hr.md +14 -9
  17. package/agents/examples/currencies-global.md +3 -4
  18. package/agents/examples/currencies-per-tenant.md +3 -4
  19. package/agents/examples/custom-fields-basic.md +2 -3
  20. package/agents/examples/custom-handlers.md +3 -5
  21. package/agents/examples/dashboard-stats.md +2 -3
  22. package/agents/examples/delivery-notifications.md +5 -6
  23. package/agents/examples/e2e-generator.md +1 -1
  24. package/agents/examples/embedded-entity-form.md +2 -3
  25. package/agents/examples/embedded.md +2 -3
  26. package/agents/examples/encrypted-tenant-config.md +5 -6
  27. package/agents/examples/error-contract.md +3 -4
  28. package/agents/examples/event-sourcing.md +19 -23
  29. package/agents/examples/feature-toggles.md +4 -4
  30. package/agents/examples/field-access.md +3 -4
  31. package/agents/examples/files-post-processing.md +12 -13
  32. package/agents/examples/golden-path.md +208 -0
  33. package/agents/examples/idempotency.md +2 -2
  34. package/agents/examples/inbound-mail-triage.md +7 -7
  35. package/agents/examples/lane-routing.md +3 -3
  36. package/agents/examples/ledger-banking.md +2 -3
  37. package/agents/examples/ledger-invoicing.md +2 -3
  38. package/agents/examples/legal-pages.md +1 -1
  39. package/agents/examples/lifecycle-hooks.md +3 -3
  40. package/agents/examples/managed-config.md +3 -4
  41. package/agents/examples/managed-pages.md +2 -2
  42. package/agents/examples/notes-history-basic.md +4 -5
  43. package/agents/examples/ownership.md +3 -5
  44. package/agents/examples/pdf-audit-export.md +2 -3
  45. package/agents/examples/pipeline-basics.md +2 -2
  46. package/agents/examples/public-share-token.md +3 -3
  47. package/agents/examples/rate-limiting.md +2 -1
  48. package/agents/examples/realtime-sse.md +2 -3
  49. package/agents/examples/record-detail-layout.md +1 -1
  50. package/agents/examples/reference-data.md +3 -3
  51. package/agents/examples/relations.md +2 -2
  52. package/agents/examples/screens-nav.md +1 -1
  53. package/agents/examples/search.md +2 -2
  54. package/agents/examples/secrets-demo.md +8 -8
  55. package/agents/examples/security-baseline.md +2 -2
  56. package/agents/examples/session-revocation.md +7 -8
  57. package/agents/examples/state-machine.md +181 -165
  58. package/agents/examples/tags-basic.md +2 -3
  59. package/agents/examples/tenant-isolation.md +2 -3
  60. package/agents/examples/tenant-settings.md +2 -2
  61. package/agents/examples/tier-admin.md +3 -4
  62. package/agents/examples/timezones.md +3 -3
  63. package/agents/examples/user-data-rights.md +12 -8
  64. package/agents/examples/user-profile.md +3 -3
  65. package/agents/examples/webhook-step.md +2 -3
  66. package/agents/examples/wizard-form.md +4 -5
  67. package/agents/examples/workflow-engine.md +7 -3
  68. package/agents/examples/writeform-section.md +1 -1
  69. package/agents/examples.md +1 -0
  70. package/agents/features.md +4 -4
  71. package/dist/consumer-cli.js +2 -3
  72. package/dist/crypto/index.d.ts +1 -1
  73. package/dist/crypto/index.js +1 -1
  74. package/dist/crypto/owner-transition-rekey.js +2 -0
  75. package/dist/crypto/pg-kms-adapter.js +1 -1
  76. package/dist/crypto/subject-resolver.d.ts +2 -0
  77. package/dist/crypto/subject-resolver.js +30 -15
  78. package/dist/db/bun-db-deprecated.d.ts +10 -0
  79. package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
  80. package/dist/db/config-seed.js +1 -1
  81. package/dist/db/connection.d.ts +23 -5
  82. package/dist/db/connection.js +10 -5
  83. package/dist/db/dialect.js +1 -1
  84. package/dist/db/entity-field-encryption.d.ts +5 -0
  85. package/dist/db/entity-field-encryption.js +29 -0
  86. package/dist/db/entity-table-meta.js +6 -5
  87. package/dist/db/event-store-executor-read.js +26 -5
  88. package/dist/db/index.d.ts +4 -4
  89. package/dist/db/index.js +1 -1
  90. package/dist/db/migrate-runner.js +1 -1
  91. package/dist/db/queries/backfill-pii.js +16 -9
  92. package/dist/db/queries/ddl.js +1 -1
  93. package/dist/db/queries/event-store.d.ts +2 -0
  94. package/dist/db/queries/event-store.js +26 -0
  95. package/dist/db/queries/test-stack.js +1 -1
  96. package/dist/db/query.d.ts +105 -1
  97. package/dist/db/query.js +1450 -3
  98. package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
  99. package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
  100. package/dist/db/tenant-db.d.ts +11 -0
  101. package/dist/db/tenant-db.js +51 -12
  102. package/dist/derivatives/derivatives-context.d.ts +1 -1
  103. package/dist/derivatives/derivatives-context.js +1 -1
  104. package/dist/engine/boot-validator/gdpr-storage.js +3 -3
  105. package/dist/engine/boot-validator/pii-retention.js +3 -3
  106. package/dist/engine/boot-validator/record-owned.js +3 -3
  107. package/dist/engine/boot-validator/screens.js +37 -1
  108. package/dist/engine/bridge-stub.d.ts +4 -0
  109. package/dist/engine/bridge-stub.js +62 -0
  110. package/dist/engine/build-config-feature-schema.js +197 -65
  111. package/dist/engine/event-helpers.d.ts +2 -1
  112. package/dist/engine/event-helpers.js +1 -0
  113. package/dist/engine/extension-selector-plugins.d.ts +3 -0
  114. package/dist/engine/extension-selector-plugins.js +14 -0
  115. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  116. package/dist/engine/feature-config-events-jobs.js +3 -2
  117. package/dist/engine/index.d.ts +1 -0
  118. package/dist/engine/index.js +1 -0
  119. package/dist/es-ops/operations-schema.js +1 -1
  120. package/dist/event-store/admin-api.js +1 -1
  121. package/dist/event-store/archive.js +1 -1
  122. package/dist/event-store/errors.d.ts +2 -1
  123. package/dist/event-store/errors.js +6 -2
  124. package/dist/event-store/event-store.js +17 -9
  125. package/dist/event-store/events-schema.js +1 -1
  126. package/dist/event-store/rebuild-dead-letter.js +3 -3
  127. package/dist/event-store/snapshot.js +1 -1
  128. package/dist/event-store/upcaster-dead-letter.js +3 -3
  129. package/dist/files/create-file-ref-from-bytes.js +1 -1
  130. package/dist/files/file-routes.js +1 -1
  131. package/dist/i18n/settings-hub-keys.js +1 -2
  132. package/dist/internal/stack-support.d.ts +12 -0
  133. package/dist/internal/stack-support.js +12 -0
  134. package/dist/internal/testing.d.ts +4 -0
  135. package/dist/internal/testing.js +5 -0
  136. package/dist/jobs/job-runner.js +23 -11
  137. package/dist/migrations/pending-rebuilds.js +1 -1
  138. package/dist/migrations/pii-event-backfill.js +2 -2
  139. package/dist/pipeline/dispatch-shared.js +43 -23
  140. package/dist/pipeline/dispatch-write.js +6 -2
  141. package/dist/pipeline/dispatcher-utils.js +6 -2
  142. package/dist/pipeline/event-consumer-state.js +1 -1
  143. package/dist/pipeline/projection-rebuild.d.ts +1 -0
  144. package/dist/pipeline/projection-rebuild.js +1 -1
  145. package/dist/pipeline/projection-state.js +1 -1
  146. package/dist/pipeline/system-hooks.js +1 -1
  147. package/dist/schema-cli.js +3 -3
  148. package/dist/search/purge-subject.js +20 -10
  149. package/dist/seeding/index.d.ts +1 -0
  150. package/dist/seeding/index.js +1 -0
  151. package/dist/stack/index.d.ts +10 -9
  152. package/dist/stack/index.js +11 -19
  153. package/dist/testing/e2e-generator.d.ts +5 -49
  154. package/dist/testing/e2e-generator.js +5 -382
  155. package/dist/testing/handler-context.d.ts +5 -4
  156. package/dist/testing/handler-context.js +5 -69
  157. package/dist/testing/index.d.ts +10 -26
  158. package/dist/testing/index.js +10 -34
  159. package/dist/testing/real-providers.d.ts +5 -3
  160. package/dist/testing/real-providers.js +5 -21
  161. package/dist/upgrade-cli.js +6 -4
  162. package/package.json +37 -9
  163. package/src/changes.json +64 -0
  164. package/src/scripts/codemod/README.md +43 -0
  165. package/src/scripts/codemod/codemod-support.ts +276 -0
  166. package/src/scripts/codemod/db-factory.ts +810 -0
  167. package/src/scripts/codemod/feature-factory.ts +429 -0
  168. package/src/scripts/codemod/s3-imports.ts +274 -0
  169. package/src/scripts/codemod/tenant-db-options.ts +454 -0
  170. package/src/scripts/codemod/testing-move-targets.ts +161 -0
  171. package/src/scripts/codemod/testing-move.ts +520 -0
  172. package/dist/bun-db/connection.d.ts +0 -17
  173. package/dist/bun-db/connection.js +0 -40
  174. package/dist/bun-db/index.d.ts +0 -4
  175. package/dist/bun-db/query.d.ts +0 -106
  176. package/dist/bun-db/query.js +0 -1457
  177. package/dist/db/api.d.ts +0 -19
  178. package/dist/db/api.js +0 -23
  179. package/dist/db/bun-provider.d.ts +0 -2
  180. package/dist/db/bun-provider.js +0 -28
  181. package/dist/db/postgres-provider.d.ts +0 -2
  182. package/dist/db/postgres-provider.js +0 -21
  183. package/dist/db/query-api.d.ts +0 -2
  184. package/dist/db/query-api.js +0 -6
  185. package/dist/stack/db.d.ts +0 -22
  186. package/dist/stack/db.js +0 -67
  187. package/dist/stack/drain-event-consumers.d.ts +0 -4
  188. package/dist/stack/drain-event-consumers.js +0 -51
  189. package/dist/stack/drain-jobs.d.ts +0 -22
  190. package/dist/stack/drain-jobs.js +0 -141
  191. package/dist/stack/event-collector.d.ts +0 -9
  192. package/dist/stack/event-collector.js +0 -12
  193. package/dist/stack/push-entity-projection-tables.d.ts +0 -24
  194. package/dist/stack/push-entity-projection-tables.js +0 -74
  195. package/dist/stack/redis.d.ts +0 -17
  196. package/dist/stack/redis.js +0 -52
  197. package/dist/stack/request-helper.d.ts +0 -35
  198. package/dist/stack/request-helper.js +0 -144
  199. package/dist/stack/test-stack.d.ts +0 -192
  200. package/dist/stack/test-stack.js +0 -429
  201. package/dist/testing/access-assertions.d.ts +0 -2
  202. package/dist/testing/access-assertions.js +0 -13
  203. package/dist/testing/assertions.d.ts +0 -10
  204. package/dist/testing/assertions.js +0 -22
  205. package/dist/testing/boot-validator-fixture.d.ts +0 -3
  206. package/dist/testing/boot-validator-fixture.js +0 -101
  207. package/dist/testing/closed-connection-error.d.ts +0 -7
  208. package/dist/testing/closed-connection-error.js +0 -46
  209. package/dist/testing/db-cleanup.d.ts +0 -9
  210. package/dist/testing/db-cleanup.js +0 -31
  211. package/dist/testing/expect-error.d.ts +0 -2
  212. package/dist/testing/expect-error.js +0 -22
  213. package/dist/testing/file-provider-contract.d.ts +0 -2
  214. package/dist/testing/file-provider-contract.js +0 -102
  215. package/dist/testing/http-cookies.d.ts +0 -7
  216. package/dist/testing/http-cookies.js +0 -47
  217. package/dist/testing/late-bound.d.ts +0 -9
  218. package/dist/testing/late-bound.js +0 -31
  219. package/dist/testing/manual-clock.d.ts +0 -14
  220. package/dist/testing/manual-clock.js +0 -12
  221. package/dist/testing/multipart-helper.d.ts +0 -22
  222. package/dist/testing/multipart-helper.js +0 -77
  223. package/dist/testing/mutable-master-key-provider.d.ts +0 -7
  224. package/dist/testing/mutable-master-key-provider.js +0 -35
  225. package/dist/testing/observability-recorder.d.ts +0 -9
  226. package/dist/testing/observability-recorder.js +0 -29
  227. package/dist/testing/seed.d.ts +0 -9
  228. package/dist/testing/seed.js +0 -21
  229. package/dist/testing/shared-entities.d.ts +0 -286
  230. package/dist/testing/shared-entities.js +0 -56
  231. package/dist/testing/utils.d.ts +0 -1
  232. package/dist/testing/utils.js +0 -1
  233. package/dist/testing/wait-for.d.ts +0 -20
  234. package/dist/testing/wait-for.js +0 -47
  235. package/dist/testing/without-ambient-temporal.d.ts +0 -5
  236. package/dist/testing/without-ambient-temporal.js +0 -17
  237. package/dist/testing/zip-fixture.d.ts +0 -1
  238. package/dist/testing/zip-fixture.js +0 -33
  239. /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
  240. /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env bun
2
+ // feature-factory — the bundled-feature constants (`tagsFeature`, ...) became factories
3
+ // (`createTagsFeature()`); the constants stay as deprecated aliases for one release.
4
+ //
5
+ // Usage:
6
+ // bun scripts/codemod/feature-factory.ts <targetDir...> [--check | --dry-run | --write] [--no-format]
7
+ // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1
8
+ // while any rewritable or manual site is left; --dry-run only reports.
9
+
10
+ import {
11
+ type ImportDeclaration,
12
+ type ImportSpecifier,
13
+ Node,
14
+ Project,
15
+ type SourceFile,
16
+ SyntaxKind,
17
+ } from "ts-morph";
18
+ import {
19
+ type CodemodResult,
20
+ type CodemodSite,
21
+ hasImportBinding,
22
+ removeNamedImport,
23
+ runCodemodCli,
24
+ type SiteLists,
25
+ sourceRelativeModule,
26
+ } from "./codemod-support.js";
27
+
28
+ export interface FeatureFactoryEntry {
29
+ readonly factory: string;
30
+ readonly folder: string;
31
+ }
32
+
33
+ const BUNDLED_PACKAGE = "@cosmicdrift/kumiko-bundled-features";
34
+ const BUNDLED_SRC_MARKER = "/packages/bundled-features/src/";
35
+
36
+ // fileProviderS3EnvFeature is not listed: s3-imports.ts folds it into createFileProviderS3Feature.
37
+ export const FEATURE_FACTORIES: Readonly<Record<string, FeatureFactoryEntry>> = {
38
+ authFoundationFeature: { factory: "createAuthFoundationFeature", folder: "auth-foundation" },
39
+ authMfaUserDataFeature: {
40
+ factory: "createAuthMfaUserDataFeature",
41
+ folder: "auth-mfa-user-data",
42
+ },
43
+ billingFoundationFeature: {
44
+ factory: "createBillingFoundationFeature",
45
+ folder: "billing-foundation",
46
+ },
47
+ capCounterFeature: { factory: "createCapCounterFeature", folder: "cap-counter" },
48
+ complianceProfilesOpsFeature: {
49
+ factory: "createComplianceProfilesOpsFeature",
50
+ folder: "compliance-profiles-ops",
51
+ },
52
+ customFieldsFeature: { factory: "createCustomFieldsFeature", folder: "custom-fields" },
53
+ derivativesSharpFeature: {
54
+ factory: "createDerivativesSharpFeature",
55
+ folder: "derivatives-sharp",
56
+ },
57
+ documentIngestFoundationFeature: {
58
+ factory: "createDocumentIngestFoundationFeature",
59
+ folder: "document-ingest-foundation",
60
+ },
61
+ fileDerivativesFeature: {
62
+ factory: "createFileDerivativesFeature",
63
+ folder: "file-derivatives",
64
+ },
65
+ fileFoundationFeature: { factory: "createFileFoundationFeature", folder: "file-foundation" },
66
+ fileProviderInMemoryFeature: {
67
+ factory: "createFileProviderInMemoryFeature",
68
+ folder: "file-provider-inmemory",
69
+ },
70
+ fileProviderS3Feature: { factory: "createFileProviderS3Feature", folder: "file-provider-s3" },
71
+ foldersFeature: { factory: "createFoldersFeature", folder: "folders" },
72
+ foldersUserDataFeature: {
73
+ factory: "createFoldersUserDataFeature",
74
+ folder: "folders-user-data",
75
+ },
76
+ formDraftFeature: { factory: "createFormDraftFeature", folder: "form-draft" },
77
+ formDraftUserDataFeature: {
78
+ factory: "createFormDraftUserDataFeature",
79
+ folder: "form-draft-user-data",
80
+ },
81
+ inboundMailFoundationFeature: {
82
+ factory: "createInboundMailFoundationFeature",
83
+ folder: "inbound-mail-foundation",
84
+ },
85
+ inboundProviderImapFeature: {
86
+ factory: "createInboundProviderImapFeature",
87
+ folder: "inbound-provider-imap",
88
+ },
89
+ inboundProviderInMemoryFeature: {
90
+ factory: "createInboundProviderInMemoryFeature",
91
+ folder: "inbound-provider-inmemory",
92
+ },
93
+ ledgerFeature: { factory: "createLedgerFeature", folder: "ledger" },
94
+ mailFoundationFeature: { factory: "createMailFoundationFeature", folder: "mail-foundation" },
95
+ mailTransportInMemoryFeature: {
96
+ factory: "createMailTransportInMemoryFeature",
97
+ folder: "mail-transport-inmemory",
98
+ },
99
+ mailTransportSmtpFeature: {
100
+ factory: "createMailTransportSmtpFeature",
101
+ folder: "mail-transport-smtp",
102
+ },
103
+ notesHistoryFeature: { factory: "createNotesHistoryFeature", folder: "notes-history" },
104
+ notesHistoryUserDataFeature: {
105
+ factory: "createNotesHistoryUserDataFeature",
106
+ folder: "notes-history-user-data",
107
+ },
108
+ readinessFeature: { factory: "createReadinessFeature", folder: "readiness" },
109
+ tagsFeature: { factory: "createTagsFeature", folder: "tags" },
110
+ templateResolverUserDataFeature: {
111
+ factory: "createTemplateResolverUserDataFeature",
112
+ folder: "template-resolver-user-data",
113
+ },
114
+ tenantSettingsFeature: { factory: "createTenantSettingsFeature", folder: "tenant-settings" },
115
+ tierEngineFeature: { factory: "createTierEngineFeature", folder: "tier-engine" },
116
+ workflowRunnerFeature: { factory: "createWorkflowRunnerFeature", folder: "workflow-runner" },
117
+ };
118
+
119
+ const CHEAP_PREFILTER = new RegExp(`\\b(${Object.keys(FEATURE_FACTORIES).join("|")})\\b`);
120
+ const SKIPPED_PATH_SUFFIXES: readonly string[] = [
121
+ "packages/framework/src/scripts/codemod/feature-factory.ts",
122
+ "packages/framework/src/__tests__/feature-factory-codemod.test.ts",
123
+ ];
124
+
125
+ const IDENTITY_OPERATORS: ReadonlySet<SyntaxKind> = new Set([
126
+ SyntaxKind.EqualsEqualsToken,
127
+ SyntaxKind.EqualsEqualsEqualsToken,
128
+ SyntaxKind.ExclamationEqualsToken,
129
+ SyntaxKind.ExclamationEqualsEqualsToken,
130
+ ]);
131
+ // Identity-comparing calls: a fresh factory result never matches the instance it is compared to.
132
+ const IDENTITY_METHODS: ReadonlySet<string> = new Set([
133
+ "toBe",
134
+ "toContain",
135
+ "includes",
136
+ "indexOf",
137
+ "lastIndexOf",
138
+ "has",
139
+ ]);
140
+
141
+ function bundledModuleFolder(specifier: string, filePath: string): string | undefined {
142
+ if (specifier.startsWith(`${BUNDLED_PACKAGE}/`))
143
+ return specifier.slice(BUNDLED_PACKAGE.length + 1);
144
+ const relativeModule = sourceRelativeModule(specifier, filePath, BUNDLED_SRC_MARKER);
145
+ return relativeModule?.module.replace(/\/(feature|index)$/, "");
146
+ }
147
+
148
+ function isIdentityUse(reference: Node): boolean {
149
+ const parent = reference.getParent();
150
+ if (Node.isBinaryExpression(parent)) {
151
+ return IDENTITY_OPERATORS.has(parent.getOperatorToken().getKind());
152
+ }
153
+ if (!Node.isCallExpression(parent) || !parent.getArguments().includes(reference)) return false;
154
+ const callee = parent.getExpression();
155
+ return (
156
+ callee.getText() === "Object.is" ||
157
+ (Node.isPropertyAccessExpression(callee) && IDENTITY_METHODS.has(callee.getName()))
158
+ );
159
+ }
160
+
161
+ interface TextEdit {
162
+ readonly start: number;
163
+ readonly end: number;
164
+ readonly text: string;
165
+ }
166
+
167
+ interface Candidate {
168
+ readonly declarationIndex: number;
169
+ readonly importedName: string;
170
+ readonly entry: FeatureFactoryEntry;
171
+ }
172
+
173
+ function referenceEdit(reference: Node, entry: FeatureFactoryEntry): TextEdit | "manual-export" {
174
+ const parent = reference.getParent();
175
+ if (Node.isTypeQuery(parent)) {
176
+ return {
177
+ start: parent.getStart(),
178
+ end: parent.getEnd(),
179
+ text: `ReturnType<typeof ${entry.factory}>`,
180
+ };
181
+ }
182
+ if (Node.isShorthandPropertyAssignment(parent)) {
183
+ return {
184
+ start: reference.getStart(),
185
+ end: reference.getEnd(),
186
+ text: `${reference.getText()}: ${entry.factory}()`,
187
+ };
188
+ }
189
+ if (Node.isExportSpecifier(parent)) return "manual-export";
190
+ return { start: reference.getStart(), end: reference.getEnd(), text: `${entry.factory}()` };
191
+ }
192
+
193
+ function site(file: string, node: Node, description: string): CodemodSite {
194
+ return { file, line: node.getStartLineNumber(), description };
195
+ }
196
+
197
+ function findCandidates(
198
+ sourceFile: SourceFile,
199
+ filePath: string,
200
+ result: SiteLists,
201
+ ): { candidates: Candidate[]; declarations: ImportDeclaration[] } {
202
+ const declarations = sourceFile.getImportDeclarations();
203
+ const candidates: Candidate[] = [];
204
+ for (const [declarationIndex, declaration] of declarations.entries()) {
205
+ const specifier = declaration.getModuleSpecifierValue();
206
+ const folder = bundledModuleFolder(specifier, filePath);
207
+ if (folder === undefined) continue;
208
+ for (const named of declaration.getNamedImports()) {
209
+ const entry = FEATURE_FACTORIES[named.getName()];
210
+ if (entry === undefined) continue;
211
+ if (entry.folder !== folder) {
212
+ result.manual.push(
213
+ site(
214
+ filePath,
215
+ named,
216
+ `${named.getName()} imported from ${specifier}: expected ${BUNDLED_PACKAGE}/${entry.folder}, use ${entry.factory}() by hand`,
217
+ ),
218
+ );
219
+ continue;
220
+ }
221
+ if (named.getAliasNode() !== undefined) {
222
+ result.manual.push(
223
+ site(
224
+ filePath,
225
+ named,
226
+ `${named.getName()} is imported under an alias: import ${entry.factory} and call it by hand`,
227
+ ),
228
+ );
229
+ continue;
230
+ }
231
+ candidates.push({ declarationIndex, importedName: named.getName(), entry });
232
+ }
233
+ }
234
+ return { candidates, declarations };
235
+ }
236
+
237
+ function referencesOutsideImports(specifier: ImportSpecifier): Node[] {
238
+ const importDeclaration = specifier.getImportDeclaration();
239
+ const nameNode = specifier.getNameNode();
240
+ if (!Node.isIdentifier(nameNode)) return [];
241
+ return nameNode
242
+ .findReferencesAsNodes()
243
+ .filter(
244
+ (reference) =>
245
+ reference.getFirstAncestor((ancestor) => ancestor === importDeclaration) === undefined,
246
+ );
247
+ }
248
+
249
+ function collectEdits(
250
+ candidates: readonly Candidate[],
251
+ declarations: readonly ImportDeclaration[],
252
+ filePath: string,
253
+ result: SiteLists,
254
+ ): { edits: TextEdit[]; applied: Candidate[] } {
255
+ const edits: TextEdit[] = [];
256
+ const applied: Candidate[] = [];
257
+ for (const candidate of candidates) {
258
+ const named = declarations[candidate.declarationIndex]
259
+ ?.getNamedImports()
260
+ .find((n) => n.getName() === candidate.importedName);
261
+ if (named === undefined) continue;
262
+ const references = referencesOutsideImports(named);
263
+ const identityUse = references.find(isIdentityUse);
264
+ if (identityUse !== undefined) {
265
+ result.manual.push(
266
+ site(
267
+ filePath,
268
+ identityUse,
269
+ `${candidate.importedName} is compared by identity; ${candidate.entry.factory}() returns a new instance`,
270
+ ),
271
+ );
272
+ continue;
273
+ }
274
+ const candidateEdits: TextEdit[] = [];
275
+ for (const reference of references) {
276
+ const edit = referenceEdit(reference, candidate.entry);
277
+ if (edit === "manual-export") {
278
+ result.manual.push(
279
+ site(
280
+ filePath,
281
+ reference,
282
+ `${candidate.importedName} is exported from this module: export ${candidate.entry.factory} as well`,
283
+ ),
284
+ );
285
+ continue;
286
+ }
287
+ candidateEdits.push(edit);
288
+ result.rewrites.push(
289
+ site(filePath, reference, `${candidate.importedName} -> ${candidate.entry.factory}()`),
290
+ );
291
+ }
292
+ edits.push(...candidateEdits);
293
+ applied.push(candidate);
294
+ }
295
+ return { edits, applied };
296
+ }
297
+
298
+ function applyEdits(source: string, edits: readonly TextEdit[]): string {
299
+ let output = source;
300
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
301
+ output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
302
+ }
303
+ return output;
304
+ }
305
+
306
+ function renameImports(
307
+ sourceFile: SourceFile,
308
+ applied: readonly Candidate[],
309
+ filePath: string,
310
+ result: SiteLists,
311
+ ): void {
312
+ for (const { importedName, entry } of applied) {
313
+ const declaration = sourceFile
314
+ .getImportDeclarations()
315
+ .find((d) => d.getNamedImports().some((n) => n.getName() === importedName));
316
+ const named = declaration?.getNamedImports().find((n) => n.getName() === importedName);
317
+ if (declaration === undefined || named === undefined) continue;
318
+ result.rewrites.push(site(filePath, named, `import ${importedName} -> ${entry.factory}`));
319
+ if (hasImportBinding(sourceFile, entry.factory)) {
320
+ removeNamedImport(sourceFile, declaration, named);
321
+ } else {
322
+ named.getNameNode().replaceWithText(entry.factory);
323
+ }
324
+ }
325
+ }
326
+
327
+ function reportNamespaceAccesses(
328
+ sourceFile: SourceFile,
329
+ filePath: string,
330
+ result: SiteLists,
331
+ ): void {
332
+ for (const declaration of sourceFile.getImportDeclarations()) {
333
+ const namespace = declaration.getNamespaceImport();
334
+ const folder = bundledModuleFolder(declaration.getModuleSpecifierValue(), filePath);
335
+ if (namespace === undefined || folder === undefined) continue;
336
+ for (const access of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
337
+ const entry = FEATURE_FACTORIES[access.getName()];
338
+ if (entry?.folder === folder && access.getExpression().getText() === namespace.getText()) {
339
+ result.manual.push(
340
+ site(
341
+ filePath,
342
+ access,
343
+ `${access.getText()}: call ${namespace.getText()}.${entry.factory}()`,
344
+ ),
345
+ );
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ function reportReexports(sourceFile: SourceFile, filePath: string, result: SiteLists): void {
352
+ for (const declaration of sourceFile.getExportDeclarations()) {
353
+ const specifier = declaration.getModuleSpecifierValue();
354
+ const folder = specifier === undefined ? undefined : bundledModuleFolder(specifier, filePath);
355
+ if (folder === undefined) continue;
356
+ const exported = declaration.getNamedExports().map((e) => e.getName());
357
+ for (const name of exported) {
358
+ const entry = FEATURE_FACTORIES[name];
359
+ if (entry?.folder !== folder || exported.includes(entry.factory)) continue;
360
+ result.manual.push(
361
+ site(
362
+ filePath,
363
+ declaration,
364
+ `${name} re-exported from ${specifier}: re-export ${entry.factory}`,
365
+ ),
366
+ );
367
+ }
368
+ }
369
+ }
370
+
371
+ function reportDynamicImports(sourceFile: SourceFile, filePath: string, result: SiteLists): void {
372
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
373
+ if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
374
+ const argument = call.getArguments()[0];
375
+ if (argument === undefined || !Node.isStringLiteral(argument)) continue;
376
+ const folder = bundledModuleFolder(argument.getLiteralValue(), filePath);
377
+ const touchesFolder = Object.values(FEATURE_FACTORIES).some((entry) => entry.folder === folder);
378
+ if (touchesFolder) {
379
+ result.manual.push(
380
+ site(
381
+ filePath,
382
+ call,
383
+ `dynamic import of ${argument.getLiteralValue()}: use the create*Feature factory by hand`,
384
+ ),
385
+ );
386
+ }
387
+ }
388
+ }
389
+
390
+ export function transformFeatureFactorySource(
391
+ source: string,
392
+ filePath = "source.ts",
393
+ ): CodemodResult {
394
+ const result: SiteLists = { rewrites: [], manual: [] };
395
+ if (!CHEAP_PREFILTER.test(source)) return { output: source, ...result };
396
+ const project = new Project({
397
+ useInMemoryFileSystem: true,
398
+ skipAddingFilesFromTsConfig: true,
399
+ skipFileDependencyResolution: true,
400
+ });
401
+ const sourceFile = project.createSourceFile(filePath, source);
402
+ reportNamespaceAccesses(sourceFile, filePath, result);
403
+ reportReexports(sourceFile, filePath, result);
404
+ reportDynamicImports(sourceFile, filePath, result);
405
+ const { candidates, declarations } = findCandidates(sourceFile, filePath, result);
406
+ const { edits, applied } = collectEdits(candidates, declarations, filePath, result);
407
+ if (applied.length === 0) return { output: source, ...result };
408
+ const withReferences = project.createSourceFile(
409
+ `${filePath}.rewritten.ts`,
410
+ applyEdits(source, edits),
411
+ );
412
+ renameImports(withReferences, applied, filePath, result);
413
+ return { output: withReferences.getFullText(), ...result };
414
+ }
415
+
416
+ function isSkippedPath(file: string): boolean {
417
+ return SKIPPED_PATH_SUFFIXES.some((suffix) => file.endsWith(suffix));
418
+ }
419
+
420
+ if (import.meta.main) {
421
+ await runCodemodCli(
422
+ {
423
+ name: "feature-factory",
424
+ transform: transformFeatureFactorySource,
425
+ isSkippedPath,
426
+ },
427
+ process.argv.slice(2),
428
+ );
429
+ }
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env bun
2
+ // s3-imports — the S3 file providers merged into one `file-provider-s3` subpath:
3
+ // .../files-provider-s3 -> .../file-provider-s3/lib
4
+ // fileProviderS3EnvFeature (.../file-provider-s3-env) -> createFileProviderS3Feature({ fromEnv: true })
5
+ // `fileProviderS3Feature` -> `createFileProviderS3Feature()` is feature-factory.ts's job; the two
6
+ // codemods leave the same result in either order.
7
+ //
8
+ // Usage:
9
+ // bun scripts/codemod/s3-imports.ts <targetDir...> [--check | --dry-run | --write] [--no-format]
10
+ // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1
11
+ // while any rewritable or manual site is left; --dry-run only reports.
12
+
13
+ import { type ImportDeclaration, Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
14
+ import {
15
+ type CodemodResult,
16
+ type CodemodSite,
17
+ ensureNamedImport,
18
+ hasImportBinding,
19
+ relativeSpecifier,
20
+ removeNamedImport,
21
+ runCodemodCli,
22
+ type SiteLists,
23
+ sourceRelativeModule,
24
+ } from "./codemod-support.js";
25
+
26
+ const BUNDLED_PACKAGE = "@cosmicdrift/kumiko-bundled-features";
27
+ const BUNDLED_SRC_MARKER = "/packages/bundled-features/src/";
28
+ const OLD_LIB_FOLDER = "files-provider-s3";
29
+ const NEW_LIB_MODULE = "file-provider-s3/lib";
30
+ const ENV_FOLDER = "file-provider-s3-env";
31
+ const MERGED_FOLDER = "file-provider-s3";
32
+ const ENV_CONSTANT = "fileProviderS3EnvFeature";
33
+ const MERGED_FACTORY = "createFileProviderS3Feature";
34
+ const ENV_CALL = `${MERGED_FACTORY}({ fromEnv: true })`;
35
+
36
+ const CHEAP_PREFILTER = /files-provider-s3|fileProviderS3EnvFeature/;
37
+ const SKIPPED_PATH_SUFFIXES: readonly string[] = [
38
+ "packages/bundled-features/src/files-provider-s3/index.ts",
39
+ "packages/bundled-features/src/file-provider-s3-env/index.ts",
40
+ "packages/framework/src/scripts/codemod/s3-imports.ts",
41
+ "packages/framework/src/__tests__/s3-imports-codemod.test.ts",
42
+ ];
43
+
44
+ function newLibSpecifier(specifier: string, filePath: string): string | undefined {
45
+ if (specifier === `${BUNDLED_PACKAGE}/${OLD_LIB_FOLDER}`) {
46
+ return `${BUNDLED_PACKAGE}/${NEW_LIB_MODULE}`;
47
+ }
48
+ const relativeModule = sourceRelativeModule(specifier, filePath, BUNDLED_SRC_MARKER);
49
+ if (relativeModule === undefined) return undefined;
50
+ const { root, module, extension } = relativeModule;
51
+ if (module !== OLD_LIB_FOLDER && !module.startsWith(`${OLD_LIB_FOLDER}/`)) return undefined;
52
+ return relativeSpecifier(
53
+ filePath,
54
+ root,
55
+ `${NEW_LIB_MODULE}${module.slice(OLD_LIB_FOLDER.length)}`,
56
+ extension,
57
+ );
58
+ }
59
+
60
+ function envModuleReplacement(specifier: string, filePath: string): string | undefined {
61
+ if (specifier === `${BUNDLED_PACKAGE}/${ENV_FOLDER}`) {
62
+ return `${BUNDLED_PACKAGE}/${MERGED_FOLDER}`;
63
+ }
64
+ const relativeModule = sourceRelativeModule(specifier, filePath, BUNDLED_SRC_MARKER);
65
+ if (relativeModule === undefined) return undefined;
66
+ const { root, module, extension } = relativeModule;
67
+ if (module !== ENV_FOLDER && !/^file-provider-s3-env\/(index|feature)$/.test(module)) {
68
+ return undefined;
69
+ }
70
+ return relativeSpecifier(
71
+ filePath,
72
+ root,
73
+ `${MERGED_FOLDER}${module.slice(ENV_FOLDER.length)}`,
74
+ extension,
75
+ );
76
+ }
77
+
78
+ function site(file: string, node: Node, description: string): CodemodSite {
79
+ return { file, line: node.getStartLineNumber(), description };
80
+ }
81
+
82
+ function stringLiteralOf(node: Node | undefined): Node | undefined {
83
+ return node !== undefined && Node.isStringLiteral(node) ? node : undefined;
84
+ }
85
+
86
+ interface TextEdit {
87
+ readonly start: number;
88
+ readonly end: number;
89
+ readonly text: string;
90
+ }
91
+
92
+ function applyEdits(source: string, edits: readonly TextEdit[]): string {
93
+ let output = source;
94
+ for (const edit of [...edits].sort((x, y) => y.start - x.start)) {
95
+ output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
96
+ }
97
+ return output;
98
+ }
99
+
100
+ function moduleSpecifierLiterals(sourceFile: SourceFile): Node[] {
101
+ const literals: Node[] = [];
102
+ for (const declaration of [
103
+ ...sourceFile.getImportDeclarations(),
104
+ ...sourceFile.getExportDeclarations(),
105
+ ]) {
106
+ const literal = declaration.getModuleSpecifier();
107
+ if (literal !== undefined) literals.push(literal);
108
+ }
109
+ for (const importType of sourceFile.getDescendantsOfKind(SyntaxKind.ImportType)) {
110
+ const argument = importType.getArgument();
111
+ if (Node.isLiteralTypeNode(argument)) literals.push(argument.getLiteral());
112
+ }
113
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
114
+ const callee = call.getExpression();
115
+ const isModuleLoad =
116
+ callee.getKind() === SyntaxKind.ImportKeyword || callee.getText() === "mock.module";
117
+ const literal = stringLiteralOf(call.getArguments()[0]);
118
+ if (isModuleLoad && literal !== undefined) literals.push(literal);
119
+ }
120
+ return literals;
121
+ }
122
+
123
+ function rewriteLibSpecifiers(
124
+ sourceFile: SourceFile,
125
+ source: string,
126
+ filePath: string,
127
+ result: SiteLists,
128
+ ): string {
129
+ const edits: TextEdit[] = [];
130
+ for (const literal of moduleSpecifierLiterals(sourceFile)) {
131
+ if (!Node.isStringLiteral(literal)) continue;
132
+ const current = literal.getLiteralValue();
133
+ const next = newLibSpecifier(current, filePath);
134
+ if (next === undefined) continue;
135
+ result.rewrites.push(site(filePath, literal, `${current} -> ${next}`));
136
+ edits.push({ start: literal.getStart(), end: literal.getEnd(), text: JSON.stringify(next) });
137
+ }
138
+ return applyEdits(source, edits);
139
+ }
140
+
141
+ function envFactoryEdit(reference: Node): TextEdit | "manual-type" | "manual-export" {
142
+ const parent = reference.getParent();
143
+ if (Node.isTypeQuery(parent)) return "manual-type";
144
+ if (Node.isExportSpecifier(parent)) return "manual-export";
145
+ const text = Node.isShorthandPropertyAssignment(parent)
146
+ ? `${ENV_CONSTANT}: ${ENV_CALL}`
147
+ : ENV_CALL;
148
+ return { start: reference.getStart(), end: reference.getEnd(), text };
149
+ }
150
+
151
+ interface EnvImportTarget {
152
+ readonly specifier: string;
153
+ readonly isTypeOnly: boolean;
154
+ }
155
+
156
+ // Collects the call-site edits of one import declaration. Returns false when a site can only be
157
+ // fixed by hand, so the import is left untouched too.
158
+ function planEnvDeclaration(
159
+ declaration: ImportDeclaration,
160
+ filePath: string,
161
+ edits: TextEdit[],
162
+ result: SiteLists,
163
+ ): boolean {
164
+ const named = declaration.getNamedImports().find((n) => n.getName() === ENV_CONSTANT);
165
+ if (named === undefined) return false;
166
+ const nameNode = named.getNameNode();
167
+ if (named.getAliasNode() !== undefined || !Node.isIdentifier(nameNode)) {
168
+ result.manual.push(
169
+ site(filePath, named, `${ENV_CONSTANT} imported under an alias: call ${ENV_CALL} by hand`),
170
+ );
171
+ return false;
172
+ }
173
+ const references = nameNode
174
+ .findReferencesAsNodes()
175
+ .filter((reference) => reference.getFirstAncestor((a) => a === declaration) === undefined);
176
+ const planned = references.map((reference) => ({ reference, edit: envFactoryEdit(reference) }));
177
+ const blocked = planned.filter(({ edit }) => typeof edit === "string");
178
+ for (const { reference, edit } of blocked) {
179
+ result.manual.push(
180
+ site(
181
+ filePath,
182
+ reference,
183
+ edit === "manual-type"
184
+ ? `typeof ${ENV_CONSTANT}: use the return type of ${MERGED_FACTORY} by hand`
185
+ : `${ENV_CONSTANT} is exported from this module: export ${MERGED_FACTORY} as well`,
186
+ ),
187
+ );
188
+ }
189
+ if (blocked.length > 0) return false;
190
+ for (const { reference, edit } of planned) {
191
+ if (typeof edit === "string") continue;
192
+ edits.push(edit);
193
+ result.rewrites.push(site(filePath, reference, `${ENV_CONSTANT} -> ${ENV_CALL}`));
194
+ }
195
+ return true;
196
+ }
197
+
198
+ function rewriteEnvFeature(source: string, filePath: string, result: SiteLists): string {
199
+ const project = new Project({
200
+ useInMemoryFileSystem: true,
201
+ skipAddingFilesFromTsConfig: true,
202
+ skipFileDependencyResolution: true,
203
+ });
204
+ const sourceFile = project.createSourceFile(filePath, source);
205
+ const edits: TextEdit[] = [];
206
+ const targetSpecifiers: EnvImportTarget[] = [];
207
+ for (const declaration of sourceFile.getImportDeclarations()) {
208
+ const replacement = envModuleReplacement(declaration.getModuleSpecifierValue(), filePath);
209
+ if (replacement === undefined) continue;
210
+ if (!planEnvDeclaration(declaration, filePath, edits, result)) continue;
211
+ targetSpecifiers.push({ specifier: replacement, isTypeOnly: declaration.isTypeOnly() });
212
+ }
213
+ if (targetSpecifiers.length === 0) return source;
214
+
215
+ const next = project.createSourceFile(`${filePath}.rewritten.ts`, applyEdits(source, edits));
216
+ for (const { specifier, isTypeOnly } of targetSpecifiers) {
217
+ const declaration = next
218
+ .getImportDeclarations()
219
+ .find((d) => d.getNamedImports().some((n) => n.getName() === ENV_CONSTANT));
220
+ const named = declaration?.getNamedImports().find((n) => n.getName() === ENV_CONSTANT);
221
+ if (declaration === undefined || named === undefined) continue;
222
+ result.rewrites.push(
223
+ site(filePath, named, `import ${ENV_CONSTANT} -> ${MERGED_FACTORY} from ${specifier}`),
224
+ );
225
+ removeNamedImport(next, declaration, named);
226
+ if (!hasImportBinding(next, MERGED_FACTORY)) {
227
+ ensureNamedImport(next, specifier, MERGED_FACTORY, isTypeOnly);
228
+ }
229
+ }
230
+ return next.getFullText();
231
+ }
232
+
233
+ function reportEnvReexports(sourceFile: SourceFile, filePath: string, result: SiteLists): void {
234
+ for (const declaration of sourceFile.getExportDeclarations()) {
235
+ const specifier = declaration.getModuleSpecifierValue();
236
+ if (specifier === undefined || envModuleReplacement(specifier, filePath) === undefined)
237
+ continue;
238
+ if (declaration.getNamedExports().some((e) => e.getName() === ENV_CONSTANT)) {
239
+ result.manual.push(
240
+ site(
241
+ filePath,
242
+ declaration,
243
+ `${ENV_CONSTANT} re-exported from ${specifier}: re-export ${MERGED_FACTORY}`,
244
+ ),
245
+ );
246
+ }
247
+ }
248
+ }
249
+
250
+ export function transformS3ImportsSource(source: string, filePath = "source.ts"): CodemodResult {
251
+ const result: SiteLists = { rewrites: [], manual: [] };
252
+ if (!CHEAP_PREFILTER.test(source)) return { output: source, ...result };
253
+ const project = new Project({
254
+ useInMemoryFileSystem: true,
255
+ skipAddingFilesFromTsConfig: true,
256
+ skipFileDependencyResolution: true,
257
+ });
258
+ const sourceFile = project.createSourceFile(filePath, source);
259
+ reportEnvReexports(sourceFile, filePath, result);
260
+ const libRewritten = rewriteLibSpecifiers(sourceFile, source, filePath, result);
261
+ const output = rewriteEnvFeature(libRewritten, filePath, result);
262
+ return { output, ...result };
263
+ }
264
+
265
+ function isSkippedPath(file: string): boolean {
266
+ return SKIPPED_PATH_SUFFIXES.some((suffix) => file.endsWith(suffix));
267
+ }
268
+
269
+ if (import.meta.main) {
270
+ await runCodemodCli(
271
+ { name: "s3-imports", transform: transformS3ImportsSource, isSkippedPath },
272
+ process.argv.slice(2),
273
+ );
274
+ }