@cosmicdrift/kumiko-framework 0.373.0 → 0.377.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 (76) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/apex-landing.md +1 -1
  3. package/agents/examples/apex-surface-auth.md +3 -3
  4. package/agents/examples/compose-stacks.md +15 -8
  5. package/agents/examples/embedded-entity-form.md +1 -1
  6. package/agents/examples/notes-history-basic.md +1 -1
  7. package/agents/examples/tags-basic.md +1 -1
  8. package/agents/examples/user-profile.md +1 -1
  9. package/agents/examples/wizard-form.md +4 -4
  10. package/agents/widgets.md +4 -3
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/index.js +1 -1
  13. package/dist/api/routes.js +2 -2
  14. package/dist/api/server.js +2 -1
  15. package/dist/config/index.d.ts +4 -0
  16. package/dist/db/index.d.ts +2 -2
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/migrate-runner.js +12 -4
  19. package/dist/db/money.js +1 -1
  20. package/dist/db/query.js +14 -14
  21. package/dist/db/replay-migration-sql.d.ts +1 -0
  22. package/dist/db/replay-migration-sql.js +115 -11
  23. package/dist/db/tenant-db.d.ts +7 -0
  24. package/dist/db/tenant-db.js +17 -5
  25. package/dist/engine/boot-validator/projection-list-screens.js +28 -37
  26. package/dist/engine/boot-validator/query-refs.js +26 -8
  27. package/dist/engine/boot-validator/screens.d.ts +1 -1
  28. package/dist/engine/boot-validator/screens.js +334 -42
  29. package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
  30. package/dist/engine/build-app-schema.js +7 -3
  31. package/dist/engine/entity-list-searchability.d.ts +5 -0
  32. package/dist/engine/entity-list-searchability.js +35 -0
  33. package/dist/engine/feature-ui-extensions.js +5 -2
  34. package/dist/engine/index.d.ts +3 -1
  35. package/dist/engine/index.js +2 -0
  36. package/dist/engine/paged-list.d.ts +29 -0
  37. package/dist/engine/paged-list.js +56 -0
  38. package/dist/engine/parse-ref-target.js +1 -1
  39. package/dist/engine/project-app-schema-for-roles.js +37 -6
  40. package/dist/engine/provider-plugin-context.d.ts +3 -0
  41. package/dist/engine/provider-plugin-context.js +28 -0
  42. package/dist/engine/schema-builder.js +7 -0
  43. package/dist/engine/types/index.d.ts +2 -2
  44. package/dist/env/index.js +1 -1
  45. package/dist/errors/zod-bridge.js +1 -1
  46. package/dist/files/file-ref-entity.d.ts +6 -6
  47. package/dist/files/file-ref-table.d.ts +6 -6
  48. package/dist/files/index.d.ts +1 -1
  49. package/dist/files/index.js +1 -1
  50. package/dist/files/provider-resolver.d.ts +17 -17
  51. package/dist/files/provider-resolver.js +1 -1
  52. package/dist/http/egress.d.ts +14 -0
  53. package/dist/http/egress.js +144 -0
  54. package/dist/http/index.d.ts +3 -2
  55. package/dist/http/index.js +2 -1
  56. package/dist/http/policy.d.ts +30 -0
  57. package/dist/http/policy.js +299 -0
  58. package/dist/i18n/required-surface-keys.js +52 -2
  59. package/dist/jobs/job-runner.js +4 -2
  60. package/dist/pipeline/config-accessor-factory.d.ts +3 -0
  61. package/dist/pipeline/config-accessor-factory.js +3 -0
  62. package/dist/pipeline/dispatch-shared.js +7 -5
  63. package/dist/pipeline/index.d.ts +1 -0
  64. package/dist/pipeline/index.js +1 -0
  65. package/dist/ui-types/index.d.ts +1 -1
  66. package/dist/upgrade-changelog.d.ts +25 -0
  67. package/dist/upgrade-changelog.js +48 -0
  68. package/dist/upgrade-cli.js +78 -13
  69. package/package.json +3 -5
  70. package/presets/biome-app.json +8 -0
  71. package/src/changes.json +132 -0
  72. package/src/scripts/codemod/README.md +25 -0
  73. package/src/scripts/codemod/codemod-support.ts +64 -6
  74. package/src/scripts/codemod/package-merge.ts +260 -0
  75. package/src/scripts/codemod/s3-imports.ts +4 -42
  76. package/src/scripts/codemod/testing-injection.ts +135 -0
@@ -1,6 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
- import { type ImportDeclaration, type ImportSpecifier, Node, type SourceFile } from "ts-morph";
3
+ import {
4
+ type ImportDeclaration,
5
+ type ImportSpecifier,
6
+ Node,
7
+ type SourceFile,
8
+ SyntaxKind,
9
+ } from "ts-morph";
4
10
  import { collectSourceFiles } from "./collect-source-files.ts";
5
11
 
6
12
  export interface CodemodSite {
@@ -48,6 +54,47 @@ export function relativeSpecifier(
48
54
  return `${path.startsWith(".") ? path : `./${path}`}${extension}`;
49
55
  }
50
56
 
57
+ function stringLiteralOf(node: Node | undefined): Node | undefined {
58
+ return node !== undefined && Node.isStringLiteral(node) ? node : undefined;
59
+ }
60
+
61
+ export interface TextEdit {
62
+ readonly start: number;
63
+ readonly end: number;
64
+ readonly text: string;
65
+ }
66
+
67
+ export function applyEdits(source: string, edits: readonly TextEdit[]): string {
68
+ let output = source;
69
+ for (const edit of [...edits].sort((x, y) => y.start - x.start)) {
70
+ output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
71
+ }
72
+ return output;
73
+ }
74
+
75
+ export function moduleSpecifierLiterals(sourceFile: SourceFile): Node[] {
76
+ const literals: Node[] = [];
77
+ for (const declaration of [
78
+ ...sourceFile.getImportDeclarations(),
79
+ ...sourceFile.getExportDeclarations(),
80
+ ]) {
81
+ const literal = declaration.getModuleSpecifier();
82
+ if (literal !== undefined) literals.push(literal);
83
+ }
84
+ for (const importType of sourceFile.getDescendantsOfKind(SyntaxKind.ImportType)) {
85
+ const argument = importType.getArgument();
86
+ if (Node.isLiteralTypeNode(argument)) literals.push(argument.getLiteral());
87
+ }
88
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
89
+ const callee = call.getExpression();
90
+ const isModuleLoad =
91
+ callee.getKind() === SyntaxKind.ImportKeyword || callee.getText() === "mock.module";
92
+ const literal = stringLiteralOf(call.getArguments()[0]);
93
+ if (isModuleLoad && literal !== undefined) literals.push(literal);
94
+ }
95
+ return literals;
96
+ }
97
+
51
98
  export function isPlainNamedImport(declaration: Node): boolean {
52
99
  return (
53
100
  Node.isImportDeclaration(declaration) &&
@@ -202,7 +249,7 @@ function parseArgs(argv: readonly string[]): CliOptions {
202
249
  return { mode: mode ?? "write", format, paths };
203
250
  }
204
251
 
205
- function isMigratableFile(name: string): boolean {
252
+ function isSourceFileName(name: string): boolean {
206
253
  return /\.(ts|tsx)$/.test(name) && !name.endsWith(".d.ts");
207
254
  }
208
255
 
@@ -215,6 +262,10 @@ export interface CodemodCliConfig {
215
262
  readonly name: string;
216
263
  readonly transform: (source: string, filePath: string) => CodemodResult;
217
264
  readonly isSkippedPath: (file: string) => boolean;
265
+ // Exact file names (e.g. package.json) handed to transform next to the .ts/.tsx sources.
266
+ readonly extraFileNames?: readonly string[];
267
+ // File extensions (e.g. ".css") handed to transform next to the .ts/.tsx sources.
268
+ readonly extraExtensions?: readonly string[];
218
269
  }
219
270
 
220
271
  function parseArgsOrExit(codemodName: string, argv: readonly string[]): CliOptions {
@@ -262,15 +313,22 @@ export async function runCodemodCli(
262
313
  argv: readonly string[],
263
314
  ): Promise<void> {
264
315
  const options = parseArgsOrExit(config.name, argv);
265
- const files = collectSourceFiles(options.paths, { isMigratableFile }).filter(
266
- (file) => !(file.includes("/__tests__/fixtures/") || config.isSkippedPath(file)),
267
- );
316
+ const files = collectSourceFiles(options.paths, {
317
+ isMigratableFile: (name) =>
318
+ isSourceFileName(name) ||
319
+ (config.extraFileNames?.includes(name) ?? false) ||
320
+ (config.extraExtensions?.some((extension) => name.endsWith(extension)) ?? false),
321
+ }).filter((file) => !(file.includes("/__tests__/fixtures/") || config.isSkippedPath(file)));
268
322
  const { rewrites, manual, changedFiles } = transformFiles(config, files, options.mode);
269
323
  console.log(
270
324
  `\n[${config.name}] ${options.mode === "write" ? "rewritten" : "rewritable"}: ${rewrites.length} site(s) in ${new Set(rewrites.map((site) => site.file)).size} file(s); manual: ${manual.length}`,
271
325
  );
272
326
  if (options.mode === "write" && options.format && changedFiles.length > 0) {
273
- await formatWithBiome(config.name, changedFiles, resolve(options.paths[0] ?? "."));
327
+ await formatWithBiome(
328
+ config.name,
329
+ changedFiles.filter((file) => isSourceFileName(file)),
330
+ resolve(options.paths[0] ?? "."),
331
+ );
274
332
  }
275
333
  process.exit(options.mode === "check" && rewrites.length + manual.length > 0 ? 1 : 0);
276
334
  }
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env bun
2
+ // package-merge — packages folded into another package keep working as shims for one wave and are
3
+ // then removed; this rewrites the consumer side of each move:
4
+ // import/export/import()/mock.module specifiers (incl. subpaths) -> the new specifier
5
+ // CSS @import/@plugin/@config specifiers -> the new specifier
6
+ // package.json dependencies/devDependencies/peerDependencies -> old entry out, new entry in
7
+ // (same version range, same block, skipped when the block already has it)
8
+ // New moves are rows in PACKAGE_MERGE_MAPPINGS; nothing else changes.
9
+ //
10
+ // Usage:
11
+ // bun scripts/codemod/package-merge.ts <targetDir...> [--check | --dry-run | --write] [--no-format]
12
+ // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1
13
+ // while any rewritable site is left; --dry-run only reports.
14
+
15
+ import { Node, Project } from "ts-morph";
16
+ import {
17
+ applyEdits,
18
+ type CodemodResult,
19
+ moduleSpecifierLiterals,
20
+ runCodemodCli,
21
+ type SiteLists,
22
+ type TextEdit,
23
+ } from "./codemod-support.ts";
24
+
25
+ export interface PackageMergeMapping {
26
+ readonly from: string;
27
+ readonly to: string;
28
+ }
29
+
30
+ // A `from` also matches its subpaths: `<from>/x` becomes `<to>/x`. Longer rows win over shorter ones.
31
+ export const PACKAGE_MERGE_MAPPINGS: readonly PackageMergeMapping[] = [
32
+ { from: "@cosmicdrift/kumiko-http", to: "@cosmicdrift/kumiko-framework/http" },
33
+ {
34
+ from: "@cosmicdrift/kumiko-repo-manifest",
35
+ to: "@cosmicdrift/kumiko-guards/repo-manifest",
36
+ },
37
+ { from: "@cosmicdrift/kumiko-headless", to: "@cosmicdrift/kumiko-renderer/headless" },
38
+ { from: "@cosmicdrift/kumiko-dispatcher-live", to: "@cosmicdrift/kumiko-renderer/live" },
39
+ { from: "@cosmicdrift/kumiko-renderer-web", to: "@cosmicdrift/kumiko-renderer/web" },
40
+ ];
41
+
42
+ const DEPENDENCY_BLOCKS = ["dependencies", "devDependencies", "peerDependencies"] as const;
43
+
44
+ const SKIPPED_PATH_SUFFIXES: readonly string[] = [
45
+ "packages/http/src/index.ts",
46
+ "packages/repo-manifest/src/index.ts",
47
+ "packages/repo-manifest/src/app-ports/index.ts",
48
+ "packages/headless/src/index.ts",
49
+ "packages/headless/src/apex/index.ts",
50
+ "packages/headless/src/dispatcher/index.ts",
51
+ "packages/headless/src/locale-routing/index.ts",
52
+ "packages/dispatcher-live/src/index.ts",
53
+ "packages/renderer-web/src/index.ts",
54
+ "packages/renderer-web/src/theme-plugin.ts",
55
+ "packages/renderer-web/src/styles.css",
56
+ "packages/framework/src/scripts/codemod/package-merge.ts",
57
+ "packages/framework/src/__tests__/package-merge-codemod.test.ts",
58
+ ];
59
+
60
+ function packageNameOf(specifier: string): string {
61
+ const [scope, name] = specifier.split("/");
62
+ return specifier.startsWith("@") ? `${scope}/${name}` : (scope ?? specifier);
63
+ }
64
+
65
+ function mappingsLongestFirst(mappings: readonly PackageMergeMapping[]): PackageMergeMapping[] {
66
+ return [...mappings].sort((a, b) => b.from.length - a.from.length);
67
+ }
68
+
69
+ export function mergedSpecifier(
70
+ specifier: string,
71
+ mappings: readonly PackageMergeMapping[] = PACKAGE_MERGE_MAPPINGS,
72
+ ): string | undefined {
73
+ for (const { from, to } of mappingsLongestFirst(mappings)) {
74
+ if (specifier === from) return to;
75
+ if (specifier.startsWith(`${from}/`)) return `${to}${specifier.slice(from.length)}`;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ function movedPackages(mappings: readonly PackageMergeMapping[]): Map<string, string> {
81
+ const moved = new Map<string, string>();
82
+ for (const { from, to } of mappings) {
83
+ if (packageNameOf(from) === from) moved.set(from, packageNameOf(to));
84
+ }
85
+ return moved;
86
+ }
87
+
88
+ function transformTypeScriptSource(
89
+ source: string,
90
+ filePath: string,
91
+ mappings: readonly PackageMergeMapping[],
92
+ result: SiteLists,
93
+ ): string {
94
+ if (!mappings.some(({ from }) => source.includes(from))) return source;
95
+ const project = new Project({
96
+ useInMemoryFileSystem: true,
97
+ skipAddingFilesFromTsConfig: true,
98
+ skipFileDependencyResolution: true,
99
+ });
100
+ const sourceFile = project.createSourceFile(filePath, source);
101
+ const edits: TextEdit[] = [];
102
+ for (const literal of moduleSpecifierLiterals(sourceFile)) {
103
+ if (!Node.isStringLiteral(literal)) continue;
104
+ const current = literal.getLiteralValue();
105
+ const next = mergedSpecifier(current, mappings);
106
+ if (next === undefined) continue;
107
+ result.rewrites.push({
108
+ file: filePath,
109
+ line: literal.getStartLineNumber(),
110
+ description: `${current} -> ${next}`,
111
+ });
112
+ edits.push({ start: literal.getStart(), end: literal.getEnd(), text: JSON.stringify(next) });
113
+ }
114
+ return applyEdits(source, edits);
115
+ }
116
+
117
+ const CSS_AT_RULE_STRING = /@(import|plugin|config|source)(\s+(?:url\(\s*)?)(["'])([^"'\n]+)\3/g;
118
+
119
+ // A stylesheet specifier is rewritten like an import. A path into the package's files (`@source
120
+ // ".../node_modules/<from>/src/..."`) has no mechanical target, so it is reported as manual.
121
+ function transformCssSource(
122
+ source: string,
123
+ filePath: string,
124
+ mappings: readonly PackageMergeMapping[],
125
+ result: SiteLists,
126
+ ): string {
127
+ if (!mappings.some(({ from }) => source.includes(from))) return source;
128
+ const lineOf = (offset: number): number => source.slice(0, offset).split("\n").length;
129
+ return source.replace(
130
+ CSS_AT_RULE_STRING,
131
+ (match, rule: string, between: string, quote: string, value: string, offset: number) => {
132
+ const next = rule === "source" ? undefined : mergedSpecifier(value, mappings);
133
+ if (next !== undefined) {
134
+ result.rewrites.push({
135
+ file: filePath,
136
+ line: lineOf(offset),
137
+ description: `${value} -> ${next}`,
138
+ });
139
+ return `@${rule}${between}${quote}${next}${quote}`;
140
+ }
141
+ if (mappings.some(({ from }) => value.includes(`/${from}/`))) {
142
+ result.manual.push({
143
+ file: filePath,
144
+ line: lineOf(offset),
145
+ description: `@${rule} path into a merged package: ${value}`,
146
+ });
147
+ }
148
+ return match;
149
+ },
150
+ );
151
+ }
152
+
153
+ function detectIndent(source: string): string {
154
+ return /^([ \t]+)"/m.exec(source)?.[1] ?? " ";
155
+ }
156
+
157
+ function isStringRecord(value: unknown): value is Record<string, string> {
158
+ return (
159
+ typeof value === "object" &&
160
+ value !== null &&
161
+ !Array.isArray(value) &&
162
+ Object.values(value).every((entry) => typeof entry === "string")
163
+ );
164
+ }
165
+
166
+ function isSortedKeys(record: Record<string, string>): boolean {
167
+ const keys = Object.keys(record);
168
+ return keys.every((key, index) => index === 0 || (keys[index - 1] ?? "") <= key);
169
+ }
170
+
171
+ function mergeDependencyBlock(
172
+ block: Record<string, string>,
173
+ moved: ReadonlyMap<string, string>,
174
+ ownName: unknown,
175
+ describe: (description: string) => void,
176
+ ): Record<string, string> | undefined {
177
+ const next: Record<string, string> = {};
178
+ let changed = false;
179
+ for (const [name, range] of Object.entries(block)) {
180
+ const target = moved.get(name);
181
+ if (target === undefined) {
182
+ next[name] = range;
183
+ continue;
184
+ }
185
+ changed = true;
186
+ describe(`${name} -> ${target}`);
187
+ if (target !== ownName && !(target in block)) next[target] = range;
188
+ }
189
+ if (!changed) return undefined;
190
+ return isSortedKeys(block)
191
+ ? Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b)))
192
+ : next;
193
+ }
194
+
195
+ function transformPackageJson(
196
+ source: string,
197
+ filePath: string,
198
+ mappings: readonly PackageMergeMapping[],
199
+ result: SiteLists,
200
+ ): string {
201
+ let manifest: unknown;
202
+ try {
203
+ manifest = JSON.parse(source);
204
+ } catch {
205
+ result.manual.push({ file: filePath, line: 1, description: "package.json is not valid JSON" });
206
+ return source;
207
+ }
208
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) return source;
209
+ const moved = movedPackages(mappings);
210
+ const record: Record<string, unknown> = { ...manifest };
211
+ let changed = false;
212
+ for (const blockName of DEPENDENCY_BLOCKS) {
213
+ const block = record[blockName];
214
+ if (!isStringRecord(block)) continue;
215
+ const merged = mergeDependencyBlock(block, moved, record["name"], (description) => {
216
+ result.rewrites.push({
217
+ file: filePath,
218
+ line: 1,
219
+ description: `${blockName}: ${description}`,
220
+ });
221
+ });
222
+ if (merged === undefined) continue;
223
+ record[blockName] = merged;
224
+ changed = true;
225
+ }
226
+ if (!changed) return source;
227
+ const trailingNewline = source.endsWith("\n") ? "\n" : "";
228
+ return `${JSON.stringify(record, null, detectIndent(source))}${trailingNewline}`;
229
+ }
230
+
231
+ export function transformPackageMergeSource(
232
+ source: string,
233
+ filePath = "source.ts",
234
+ mappings: readonly PackageMergeMapping[] = PACKAGE_MERGE_MAPPINGS,
235
+ ): CodemodResult {
236
+ const result: SiteLists = { rewrites: [], manual: [] };
237
+ const output = filePath.endsWith("package.json")
238
+ ? transformPackageJson(source, filePath, mappings, result)
239
+ : filePath.endsWith(".css")
240
+ ? transformCssSource(source, filePath, mappings, result)
241
+ : transformTypeScriptSource(source, filePath, mappings, result);
242
+ return { output, ...result };
243
+ }
244
+
245
+ function isSkippedPath(file: string): boolean {
246
+ return SKIPPED_PATH_SUFFIXES.some((suffix) => file.endsWith(suffix));
247
+ }
248
+
249
+ if (import.meta.main) {
250
+ await runCodemodCli(
251
+ {
252
+ name: "package-merge",
253
+ transform: (source, filePath) => transformPackageMergeSource(source, filePath),
254
+ isSkippedPath,
255
+ extraFileNames: ["package.json"],
256
+ extraExtensions: [".css"],
257
+ },
258
+ process.argv.slice(2),
259
+ );
260
+ }
@@ -10,17 +10,20 @@
10
10
  // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1
11
11
  // while any rewritable or manual site is left; --dry-run only reports.
12
12
 
13
- import { type ImportDeclaration, Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
13
+ import { type ImportDeclaration, Node, Project, type SourceFile } from "ts-morph";
14
14
  import {
15
+ applyEdits,
15
16
  type CodemodResult,
16
17
  type CodemodSite,
17
18
  ensureNamedImport,
18
19
  hasImportBinding,
20
+ moduleSpecifierLiterals,
19
21
  relativeSpecifier,
20
22
  removeNamedImport,
21
23
  runCodemodCli,
22
24
  type SiteLists,
23
25
  sourceRelativeModule,
26
+ type TextEdit,
24
27
  } from "./codemod-support.ts";
25
28
 
26
29
  const BUNDLED_PACKAGE = "@cosmicdrift/kumiko-bundled-features";
@@ -79,47 +82,6 @@ function site(file: string, node: Node, description: string): CodemodSite {
79
82
  return { file, line: node.getStartLineNumber(), description };
80
83
  }
81
84
 
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
85
  function rewriteLibSpecifiers(
124
86
  sourceFile: SourceFile,
125
87
  source: string,
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bun
2
+ // testing-injection — kumiko-testing no longer imports bundled-features or dev-server. The test
3
+ // functions that need them are bound in `@cosmicdrift/kumiko-dev-server/testing`:
4
+ // @cosmicdrift/kumiko-testing { setupAppTestStack, SetupAppTestStackOptions,
5
+ // seedTenant, persistTenantRows }
6
+ // @cosmicdrift/kumiko-testing/e2e/seed-route { createE2eSeedRoutes }
7
+ // -> @cosmicdrift/kumiko-dev-server/testing
8
+ // Names that stay (types like SeededTenant, E2eExtraSeeder, isE2eSeedingEnabled) keep their import.
9
+ //
10
+ // Usage:
11
+ // bun scripts/codemod/testing-injection.ts <targetDir...> [--check | --dry-run | --write] [--no-format]
12
+ // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1
13
+ // while any rewritable or manual site is left; --dry-run only reports.
14
+
15
+ import { type ImportDeclaration, Project, SyntaxKind } from "ts-morph";
16
+ import {
17
+ type CodemodResult,
18
+ ensureNamedImport,
19
+ runCodemodCli,
20
+ type SiteLists,
21
+ } from "./codemod-support.ts";
22
+
23
+ const TESTING_PACKAGE = "@cosmicdrift/kumiko-testing";
24
+ const TARGET_MODULE = "@cosmicdrift/kumiko-dev-server/testing";
25
+
26
+ const MOVED_NAMES_BY_MODULE: ReadonlyMap<string, ReadonlySet<string>> = new Map([
27
+ [
28
+ TESTING_PACKAGE,
29
+ new Set(["setupAppTestStack", "SetupAppTestStackOptions", "seedTenant", "persistTenantRows"]),
30
+ ],
31
+ [`${TESTING_PACKAGE}/e2e/seed-route`, new Set(["createE2eSeedRoutes"])],
32
+ ]);
33
+
34
+ const CHEAP_PREFILTER = /kumiko-testing/;
35
+ const SKIPPED_PATH_SUFFIXES: readonly string[] = [
36
+ "packages/framework/src/scripts/codemod/testing-injection.ts",
37
+ "packages/framework/src/__tests__/testing-injection-codemod.test.ts",
38
+ "packages/dev-server/src/testing.ts",
39
+ ];
40
+
41
+ function movedNamesOf(declaration: ImportDeclaration): ReadonlySet<string> | undefined {
42
+ return MOVED_NAMES_BY_MODULE.get(declaration.getModuleSpecifierValue());
43
+ }
44
+
45
+ function reportNamespaceUses(
46
+ declaration: ImportDeclaration,
47
+ moved: ReadonlySet<string>,
48
+ filePath: string,
49
+ result: SiteLists,
50
+ ): void {
51
+ const namespaceName = declaration.getNamespaceImport()?.getText();
52
+ const accesses =
53
+ namespaceName === undefined
54
+ ? []
55
+ : declaration.getSourceFile().getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);
56
+ for (const access of accesses) {
57
+ if (access.getExpression().getText() === namespaceName && moved.has(access.getName())) {
58
+ result.manual.push({
59
+ file: filePath,
60
+ line: access.getStartLineNumber(),
61
+ description: `${namespaceName}.${access.getName()}: import it from ${TARGET_MODULE}`,
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ export function transformTestingInjectionSource(
68
+ source: string,
69
+ filePath = "source.ts",
70
+ ): CodemodResult {
71
+ const result: SiteLists = { rewrites: [], manual: [] };
72
+ if (!CHEAP_PREFILTER.test(source)) return { output: source, ...result };
73
+ const project = new Project({
74
+ useInMemoryFileSystem: true,
75
+ skipAddingFilesFromTsConfig: true,
76
+ skipFileDependencyResolution: true,
77
+ });
78
+ const sourceFile = project.createSourceFile(filePath, source);
79
+ for (const declaration of sourceFile.getImportDeclarations()) {
80
+ const moved = movedNamesOf(declaration);
81
+ if (moved === undefined) continue;
82
+ reportNamespaceUses(declaration, moved, filePath, result);
83
+ const movedImports = declaration
84
+ .getNamedImports()
85
+ .filter((named) => moved.has(named.getName()));
86
+ if (movedImports.length === 0) continue;
87
+ const line = declaration.getStartLineNumber();
88
+ const typeOnlyDeclaration = declaration.isTypeOnly();
89
+ const names = movedImports.map((named) => named.getName());
90
+ // Collected first: removing a specifier invalidates the nodes of its siblings.
91
+ const snapshots = movedImports.map((named) => ({
92
+ name: named.getName(),
93
+ alias: named.getAliasNode()?.getText(),
94
+ typeOnly: named.isTypeOnly(),
95
+ }));
96
+ for (const named of movedImports) named.remove();
97
+ if (
98
+ declaration.getNamedImports().length === 0 &&
99
+ declaration.getNamespaceImport() === undefined &&
100
+ declaration.getDefaultImport() === undefined
101
+ ) {
102
+ declaration.remove();
103
+ }
104
+ for (const snapshot of snapshots) {
105
+ const typeOnly = typeOnlyDeclaration || snapshot.typeOnly;
106
+ if (snapshot.alias === undefined) {
107
+ ensureNamedImport(sourceFile, TARGET_MODULE, snapshot.name, typeOnly);
108
+ } else {
109
+ sourceFile.addImportDeclaration({
110
+ moduleSpecifier: TARGET_MODULE,
111
+ namedImports: [{ name: snapshot.name, alias: snapshot.alias }],
112
+ isTypeOnly: typeOnly,
113
+ });
114
+ }
115
+ }
116
+ result.rewrites.push({
117
+ file: filePath,
118
+ line,
119
+ description: `${names.join(", ")} -> ${TARGET_MODULE}`,
120
+ });
121
+ }
122
+ const output = result.rewrites.length > 0 ? sourceFile.getFullText() : source;
123
+ return { output, ...result };
124
+ }
125
+
126
+ function isSkippedPath(file: string): boolean {
127
+ return SKIPPED_PATH_SUFFIXES.some((suffix) => file.endsWith(suffix));
128
+ }
129
+
130
+ if (import.meta.main) {
131
+ await runCodemodCli(
132
+ { name: "testing-injection", transform: transformTestingInjectionSource, isSkippedPath },
133
+ process.argv.slice(2),
134
+ );
135
+ }