@cosmicdrift/kumiko-feature-ast 0.391.0 → 0.392.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-feature-ast",
3
- "version": "0.391.0",
3
+ "version": "0.392.0",
4
4
  "description": "Feature-file AST for Kumiko: parse, patch and render feature definitions, the pattern library, and the codemods kumiko-upgrade runs.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -22,8 +22,8 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@cosmicdrift/kumiko-framework": "0.391.0",
26
- "@cosmicdrift/kumiko-types": "0.391.0",
25
+ "@cosmicdrift/kumiko-framework": "0.392.0",
26
+ "@cosmicdrift/kumiko-types": "0.392.0",
27
27
  "ts-morph": "^28.0.0",
28
28
  "zod": "^4.6.7"
29
29
  },
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "improvement",
5
+ "title": "Codemod entries return their exit code",
6
+ "detail": "The feature-ast codemods export main(argv) and runCodemod next to runCodemodCli; they return an exit code instead of calling process.exit, so callers can run them in-process. Invocation from the shell is unchanged."
7
+ },
8
+ {
9
+ "version": "0.391.1",
10
+ "type": "fix",
11
+ "title": "Type-aware codemods no longer exhaust memory when the app pins another zod",
12
+ "detail": "ctx-namespaces and remove-engine-payload-casts build their ts-morph project with a resolution host that resolves zod and its subpaths from the app root, so the program holds one zod copy even when bun nests a second version under each framework package. Without zod at the app root, resolution is unchanged. A small app with zod 4.6.6 next to framework packages on 4.6.7 went from 17 GB and 36 s to 1.1 GB and 3 s for ctx-namespaces, with the same rewrites."
13
+ },
2
14
  {
3
15
  "version": "0.391.0",
4
16
  "type": "fix",
@@ -286,13 +286,16 @@ export interface CodemodCliConfig {
286
286
  readonly extraExtensions?: readonly string[];
287
287
  }
288
288
 
289
- function parseArgsOrExit(codemodName: string, argv: readonly string[]): CliOptions {
289
+ function parseArgsOrUsageError(
290
+ codemodName: string,
291
+ argv: readonly string[],
292
+ ): CliOptions | { readonly exitCode: number } {
290
293
  try {
291
294
  return parseArgs(argv);
292
295
  } catch (err) {
293
296
  if (err instanceof CliError) {
294
297
  console.error(`[${codemodName}] ${err.message}`);
295
- process.exit(2);
298
+ return { exitCode: 2 };
296
299
  }
297
300
  throw err;
298
301
  }
@@ -329,8 +332,17 @@ function transformFiles(
329
332
  export async function runCodemodCli(
330
333
  config: CodemodCliConfig,
331
334
  argv: readonly string[],
332
- ): Promise<void> {
333
- const options = parseArgsOrExit(config.name, argv);
335
+ ): Promise<never> {
336
+ process.exit(await runCodemod(config, argv));
337
+ }
338
+
339
+ // Exit code instead of process.exit so the CLI logic can run in-process (tests).
340
+ export async function runCodemod(
341
+ config: CodemodCliConfig,
342
+ argv: readonly string[],
343
+ ): Promise<number> {
344
+ const options = parseArgsOrUsageError(config.name, argv);
345
+ if ("exitCode" in options) return options.exitCode;
334
346
  const files = collectSourceFiles(options.paths, {
335
347
  isMigratableFile: (name) =>
336
348
  isSourceFileName(name) ||
@@ -348,5 +360,5 @@ export async function runCodemodCli(
348
360
  resolve(options.paths[0] ?? "."),
349
361
  );
350
362
  }
351
- process.exit(options.mode === "check" && rewrites.length + manual.length > 0 ? 1 : 0);
363
+ return options.mode === "check" && rewrites.length + manual.length > 0 ? 1 : 0;
352
364
  }
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
- import { Project } from "ts-morph";
4
3
  import { type CodemodSite, formatWithBiome } from "./codemod-support.ts";
5
4
  import { collectSourceFiles } from "./collect-source-files.ts";
6
5
  import { transformCtxNamespaces } from "./ctx-namespaces-transform.ts";
6
+ import { createTypeAwareProject } from "./type-aware-project.ts";
7
7
 
8
8
  const NAME = "ctx-namespaces";
9
9
 
@@ -101,10 +101,7 @@ function runGroup(
101
101
  mode: Mode,
102
102
  totals: RunTotals,
103
103
  ): void {
104
- const project = new Project({
105
- ...(tsconfig === undefined ? {} : { tsConfigFilePath: tsconfig }),
106
- skipAddingFilesFromTsConfig: true,
107
- });
104
+ const project = createTypeAwareProject(tsconfig);
108
105
  totals.projects += 1;
109
106
  // Receivers are found through references: a structural helper is only recognised as taking a
110
107
  // handler context when its callers in other files are already part of the project.
@@ -127,14 +124,14 @@ function runGroup(
127
124
 
128
125
  // kumiko-upgrade runs this as `bun ctx-namespaces.ts <repoDir> [--dry-run]` and treats a non-zero
129
126
  // exit as a failed codemod, so manual sites alone must not fail write or dry-run.
130
- async function main(argv: readonly string[]): Promise<void> {
127
+ export async function main(argv: readonly string[]): Promise<number> {
131
128
  let options: CliOptions;
132
129
  try {
133
130
  options = parseArgs(argv);
134
131
  } catch (error) {
135
132
  if (error instanceof CliError) {
136
133
  console.error(`[${NAME}] ${error.message}`);
137
- process.exit(2);
134
+ return 2;
138
135
  }
139
136
  throw error;
140
137
  }
@@ -160,11 +157,9 @@ async function main(argv: readonly string[]): Promise<void> {
160
157
  if (options.mode === "write" && options.format && totals.changedFiles.length > 0) {
161
158
  await formatWithBiome(NAME, totals.changedFiles, resolve(options.paths[0] ?? "."));
162
159
  }
163
- process.exit(
164
- options.mode === "check" && totals.rewrites.length + totals.manual.length > 0 ? 1 : 0,
165
- );
160
+ return options.mode === "check" && totals.rewrites.length + totals.manual.length > 0 ? 1 : 0;
166
161
  }
167
162
 
168
163
  if (import.meta.main) {
169
- await main(process.argv.slice(2));
164
+ process.exit(await main(process.argv.slice(2)));
170
165
  }
@@ -389,13 +389,13 @@ function writeResult(result: FeatureLayoutResult): void {
389
389
  }
390
390
  }
391
391
 
392
- async function main(argv: readonly string[]): Promise<void> {
392
+ export async function main(argv: readonly string[]): Promise<number> {
393
393
  let options: CliOptions;
394
394
  try {
395
395
  options = parseArgs(argv);
396
396
  } catch (err) {
397
397
  console.error(`[feature-layout] ${err instanceof Error ? err.message : String(err)}`);
398
- process.exit(2);
398
+ return 2;
399
399
  }
400
400
  const files = readTargetFiles(options.paths);
401
401
  const result = applyFeatureLayout(files, options);
@@ -418,7 +418,7 @@ async function main(argv: readonly string[]): Promise<void> {
418
418
  }
419
419
  }
420
420
  const pending = result.markersRemoved.length + result.manual.length;
421
- process.exit(options.mode === "check" && pending > 0 ? 1 : 0);
421
+ return options.mode === "check" && pending > 0 ? 1 : 0;
422
422
  }
423
423
 
424
- if (import.meta.main) await main(process.argv.slice(2));
424
+ if (import.meta.main) process.exit(await main(process.argv.slice(2)));
@@ -683,13 +683,15 @@ function countByMethod(rewrites: readonly ObjectFormRewrite[]): string {
683
683
  .join(", ");
684
684
  }
685
685
 
686
- function parseArgsOrExit(argv: readonly string[]): CliOptions {
686
+ function parseArgsOrUsageError(
687
+ argv: readonly string[],
688
+ ): CliOptions | { readonly exitCode: number } {
687
689
  try {
688
690
  return parseArgs(argv);
689
691
  } catch (err) {
690
692
  if (err instanceof CliError) {
691
693
  console.error(`[object-form] ${err.message}`);
692
- process.exit(2);
694
+ return { exitCode: 2 };
693
695
  }
694
696
  throw err;
695
697
  }
@@ -722,8 +724,9 @@ function transformFiles(files: readonly string[], mode: Mode): RunSummary {
722
724
  return summary;
723
725
  }
724
726
 
725
- async function main(): Promise<void> {
726
- const options = parseArgsOrExit(process.argv.slice(2));
727
+ export async function main(argv: readonly string[]): Promise<number> {
728
+ const options = parseArgsOrUsageError(argv);
729
+ if ("exitCode" in options) return options.exitCode;
727
730
  const files = collectSourceFiles(options.paths, { isMigratableFile }).filter(
728
731
  (file) => !isFixturePath(file),
729
732
  );
@@ -738,9 +741,9 @@ async function main(): Promise<void> {
738
741
  }
739
742
 
740
743
  const hasOpenSites = rewrites.length + manual.length > 0;
741
- process.exit(options.mode === "check" && hasOpenSites ? 1 : 0);
744
+ return options.mode === "check" && hasOpenSites ? 1 : 0;
742
745
  }
743
746
 
744
747
  if (import.meta.main) {
745
- await main();
748
+ process.exit(await main(process.argv.slice(2)));
746
749
  }
@@ -15,6 +15,7 @@
15
15
  import { Node, Project } from "ts-morph";
16
16
  import {
17
17
  applyEdits,
18
+ type CodemodCliConfig,
18
19
  type CodemodResult,
19
20
  detectIndent,
20
21
  isSortedKeys,
@@ -270,15 +271,14 @@ function isSkippedPath(file: string): boolean {
270
271
  return SKIPPED_PATH_SUFFIXES.some((suffix) => file.endsWith(suffix));
271
272
  }
272
273
 
274
+ export const PACKAGE_MERGE_CLI: CodemodCliConfig = {
275
+ name: "package-merge",
276
+ transform: (source, filePath) => transformPackageMergeSource(source, filePath),
277
+ isSkippedPath,
278
+ extraFileNames: ["package.json"],
279
+ extraExtensions: [".css"],
280
+ };
281
+
273
282
  if (import.meta.main) {
274
- await runCodemodCli(
275
- {
276
- name: "package-merge",
277
- transform: (source, filePath) => transformPackageMergeSource(source, filePath),
278
- isSkippedPath,
279
- extraFileNames: ["package.json"],
280
- extraExtensions: [".css"],
281
- },
282
- process.argv.slice(2),
283
- );
283
+ await runCodemodCli(PACKAGE_MERGE_CLI, process.argv.slice(2));
284
284
  }
@@ -13,8 +13,9 @@
13
13
  // program, so the framework version the app compiles against decides the row type.
14
14
 
15
15
  import { dirname, resolve } from "node:path";
16
- import { type AsExpression, Node, Project, type SourceFile, SyntaxKind, ts } from "ts-morph";
16
+ import { type AsExpression, Node, type Project, type SourceFile, SyntaxKind, ts } from "ts-morph";
17
17
  import { type CodemodResult, runCodemodCli } from "./codemod-support.ts";
18
+ import { createTypeAwareProject } from "./type-aware-project.ts";
18
19
 
19
20
  const CAST_MARKER = "@cast-boundary";
20
21
  const EXECUTOR_TYPES_FILE = "/event-store-executor-types.ts";
@@ -278,7 +279,7 @@ function projectFor(filePath: string): Project | undefined {
278
279
  if (tsconfig === undefined) return undefined;
279
280
  const cached = projectsByTsconfig.get(tsconfig);
280
281
  if (cached !== undefined) return cached;
281
- const project = new Project({ tsConfigFilePath: tsconfig, skipAddingFilesFromTsConfig: true });
282
+ const project = createTypeAwareProject(tsconfig);
282
283
  projectsByTsconfig.set(tsconfig, project);
283
284
  return project;
284
285
  }
@@ -506,20 +506,23 @@ function transformFiles(files: readonly string[], mode: Mode): RunSummary {
506
506
  return summary;
507
507
  }
508
508
 
509
- function parseArgsOrExit(argv: readonly string[]): CliOptions {
509
+ function parseArgsOrUsageError(
510
+ argv: readonly string[],
511
+ ): CliOptions | { readonly exitCode: number } {
510
512
  try {
511
513
  return parseArgs(argv);
512
514
  } catch (err) {
513
515
  if (err instanceof CliError) {
514
516
  console.error(`[testing-move] ${err.message}`);
515
- process.exit(2);
517
+ return { exitCode: 2 };
516
518
  }
517
519
  throw err;
518
520
  }
519
521
  }
520
522
 
521
- async function main(): Promise<void> {
522
- const options = parseArgsOrExit(process.argv.slice(2));
523
+ export async function main(argv: readonly string[]): Promise<number> {
524
+ const options = parseArgsOrUsageError(argv);
525
+ if ("exitCode" in options) return options.exitCode;
523
526
  const files = collectSourceFiles(options.paths, { isMigratableFile }).filter(
524
527
  (file) => !isSkippedPath(file),
525
528
  );
@@ -534,9 +537,9 @@ async function main(): Promise<void> {
534
537
  }
535
538
 
536
539
  const hasOpenSites = rewrites.length + manual.length > 0;
537
- process.exit(options.mode === "check" && hasOpenSites ? 1 : 0);
540
+ return options.mode === "check" && hasOpenSites ? 1 : 0;
538
541
  }
539
542
 
540
543
  if (import.meta.main) {
541
- await main();
544
+ process.exit(await main(process.argv.slice(2)));
542
545
  }
@@ -0,0 +1,47 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { Project, type ResolutionHostFactory, ts } from "ts-morph";
3
+
4
+ function isZodSpecifier(moduleName: string): boolean {
5
+ return moduleName === "zod" || moduleName.startsWith("zod/");
6
+ }
7
+
8
+ // A second zod nested under a package makes the checker compare schemas structurally across both
9
+ // copies at every framework call, which exhausts memory; one copy keeps them nominal.
10
+ function appRootZodResolution(appRoot: string): ResolutionHostFactory {
11
+ const appRootAnchor = join(appRoot, "codemod-resolution-anchor.ts");
12
+ return (moduleResolutionHost, getCompilerOptions) => {
13
+ const cache = ts.createModuleResolutionCache(
14
+ appRoot,
15
+ (fileName) => fileName,
16
+ getCompilerOptions(),
17
+ );
18
+ const resolveFrom = (moduleName: string, containingFile: string, options: ts.CompilerOptions) =>
19
+ ts.resolveModuleName(moduleName, containingFile, options, moduleResolutionHost, cache)
20
+ .resolvedModule;
21
+ return {
22
+ resolveModuleNames: (
23
+ moduleNames,
24
+ containingFile,
25
+ _reusedNames,
26
+ _redirectedReference,
27
+ options,
28
+ ) =>
29
+ moduleNames.map(
30
+ (moduleName) =>
31
+ (isZodSpecifier(moduleName)
32
+ ? resolveFrom(moduleName, appRootAnchor, options)
33
+ : undefined) ?? resolveFrom(moduleName, containingFile, options),
34
+ ),
35
+ };
36
+ };
37
+ }
38
+
39
+ export function createTypeAwareProject(tsconfig: string | undefined): Project {
40
+ if (tsconfig === undefined) return new Project({ skipAddingFilesFromTsConfig: true });
41
+ const tsConfigFilePath = resolve(tsconfig);
42
+ return new Project({
43
+ tsConfigFilePath,
44
+ skipAddingFilesFromTsConfig: true,
45
+ resolutionHost: appRootZodResolution(dirname(tsConfigFilePath)),
46
+ });
47
+ }
@@ -365,9 +365,9 @@ function migrateFile(sourceFile: SourceFile, skips: SkipEntry[]): number {
365
365
  return removedCount;
366
366
  }
367
367
 
368
- async function main(): Promise<void> {
369
- const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
370
- const dryRun = process.argv.includes("--dry-run");
368
+ export async function main(argv: readonly string[]): Promise<number> {
369
+ const positional = argv.filter((a) => !a.startsWith("--"));
370
+ const dryRun = argv.includes("--dry-run");
371
371
  const rootDir = resolve(positional[0] ?? process.cwd());
372
372
 
373
373
  const files = findTargetFiles(rootDir);
@@ -406,6 +406,7 @@ async function main(): Promise<void> {
406
406
  }
407
407
  }
408
408
  console.log();
409
+ return 0;
409
410
  }
410
411
 
411
- await main();
412
+ if (import.meta.main) process.exit(await main(process.argv.slice(2)));