From b361d99e68c615899e27329ddee4eb28d43dcce0 Mon Sep 17 00:00:00 2001 From: Jonathan Schneider Date: Fri, 1 May 2026 17:14:46 -0400 Subject: [PATCH 1/4] RPC peers: emit empty arrays for descriptor collections, never omit them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Recipe.getDescriptor()` on the Java side always returns a descriptor whose collection-valued getters (`tags`, `options`, `preconditions`, `recipeList`, `dataTables`, `maintainers`, `contributors`, `examples`) are non-null. Callers across the ecosystem rely on that and iterate the getters without null checks. When a `RecipeDescriptor` is produced by a polyglot RPC peer, however, the JSON those peers emit can omit empty collections — Jackson then deserializes the corresponding fields as `null`, breaking every downstream caller (e.g. `descriptor.getPreconditions().iterator()`). Fix the Python and TypeScript peers to always emit every collection key, even when empty. The C# peer was already correct: `RecipeDescriptorDto` declares each list/set property with an `= []` initializer and Newtonsoft serializes empty collections as `[]`, so the JSON it produces already matches the contract. Python (`rewrite/src/rewrite/rpc/server.py`): Add `preconditions`, `maintainers`, `contributors`, `examples` as empty lists in `_recipe_descriptor_to_dict`. (The Python `RecipeDescriptor` dataclass doesn't model these fields yet, so they're hardcoded.) TypeScript (`rewrite/src/recipe.ts`): Extend the `RecipeDescriptor` interface with the four missing fields and have `Recipe.descriptor()` populate them. Updates the existing `recipe.test.ts` snapshot. --- rewrite-javascript/rewrite/src/recipe.ts | 19 ++++++++++--- .../rewrite/test/recipe.test.ts | 6 ++++- .../rewrite/src/rewrite/rpc/server.py | 14 ++++++++-- .../rewrite/tests/rpc/test_server.py | 27 +++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/rewrite-javascript/rewrite/src/recipe.ts b/rewrite-javascript/rewrite/src/recipe.ts index c63b5dc4dd5..78dd1096a02 100644 --- a/rewrite-javascript/rewrite/src/recipe.ts +++ b/rewrite-javascript/rewrite/src/recipe.ts @@ -89,6 +89,11 @@ export abstract class Recipe { async descriptor(): Promise { const optionsRecord: Record = (this as any).constructor[OPTIONS_KEY] || {} + // Java's `RecipeDescriptor.getXxx()` getters for collection-valued + // fields are treated as never-null by callers (matching what + // `Recipe.getDescriptor()` upholds locally). Always emit the + // collection keys, even when empty, so Jackson on the Java side + // never leaves them null. return { name: this.name, displayName: this.displayName, @@ -96,14 +101,18 @@ export abstract class Recipe { description: this.description, tags: this.tags, estimatedEffortPerOccurrence: this.estimatedEffortPerOccurrence, - recipeList: await mapAsync(await this.recipeList(), async r => r.descriptor()), options: Object.entries(optionsRecord).map(([key, descriptor]) => ({ name: key, value: (this as any)[key], required: descriptor.required ?? true, ...descriptor })), - dataTables: this.dataTables + preconditions: [], + recipeList: await mapAsync(await this.recipeList(), async r => r.descriptor()), + dataTables: this.dataTables, + maintainers: [], + contributors: [], + examples: [] } } @@ -135,9 +144,13 @@ export interface RecipeDescriptor { readonly description: string readonly tags: string[] readonly estimatedEffortPerOccurrence: Minutes - readonly recipeList: RecipeDescriptor[] readonly options: ({ name: string, value?: any } & OptionDescriptor)[] + readonly preconditions: RecipeDescriptor[] + readonly recipeList: RecipeDescriptor[] readonly dataTables: DataTableDescriptor[] + readonly maintainers: any[] + readonly contributors: any[] + readonly examples: any[] } export interface OptionDescriptor { diff --git a/rewrite-javascript/rewrite/test/recipe.test.ts b/rewrite-javascript/rewrite/test/recipe.test.ts index 45346f7df21..350e735f07c 100644 --- a/rewrite-javascript/rewrite/test/recipe.test.ts +++ b/rewrite-javascript/rewrite/test/recipe.test.ts @@ -43,9 +43,13 @@ describe("recipes", () => { value: undefined } ], + preconditions: [], recipeList: [], tags: [], - dataTables: [] + dataTables: [], + maintainers: [], + contributors: [], + examples: [] }); }); }); diff --git a/rewrite-python/rewrite/src/rewrite/rpc/server.py b/rewrite-python/rewrite/src/rewrite/rpc/server.py index 67927082c64..c8156e7dae2 100644 --- a/rewrite-python/rewrite/src/rewrite/rpc/server.py +++ b/rewrite-python/rewrite/src/rewrite/rpc/server.py @@ -878,7 +878,13 @@ def _category_descriptor_to_dict(descriptor) -> dict: def _recipe_descriptor_to_dict(descriptor) -> dict: - """Convert a RecipeDescriptor to a dict for JSON serialization.""" + """Convert a RecipeDescriptor to a dict for JSON serialization. + + Java's `RecipeDescriptor.getXxx()` getters for collection-valued fields + are treated as never-null by callers (matching what `Recipe.getDescriptor()` + upholds locally). Always emit the collection keys, even when empty, so + Jackson on the Java side never leaves them null. + """ return { 'name': descriptor.name, 'displayName': descriptor.display_name, @@ -897,8 +903,12 @@ def _recipe_descriptor_to_dict(descriptor) -> dict: } for name, value, opt in descriptor.options ], - 'dataTables': descriptor.data_tables, + 'preconditions': [], 'recipeList': [_recipe_descriptor_to_dict(r) for r in descriptor.recipe_list], + 'dataTables': descriptor.data_tables, + 'maintainers': [], + 'contributors': [], + 'examples': [], } diff --git a/rewrite-python/rewrite/tests/rpc/test_server.py b/rewrite-python/rewrite/tests/rpc/test_server.py index 0c4f3ddfed0..4798c525b6c 100644 --- a/rewrite-python/rewrite/tests/rpc/test_server.py +++ b/rewrite-python/rewrite/tests/rpc/test_server.py @@ -24,3 +24,30 @@ def fake_parse_python_source(source, path="", relative_to=None, ty_clie assert observed["source"] == "" assert observed["path"] == str(tmp_path / "pkg" / "__init__.py") assert (tmp_path / "pkg" / "__init__.py").read_text(encoding="utf-8") == "" + + +def test_recipe_descriptor_to_dict_emits_all_collection_keys(): + """Java's RecipeDescriptor.getXxx() collection-valued getters are + treated as never-null by callers. Always emit the collection keys — + including the ones the Python dataclass doesn't model — so Jackson on + the Java side never leaves them null.""" + from rewrite.recipe import RecipeDescriptor + from rewrite.rpc.server import _recipe_descriptor_to_dict + + descriptor = RecipeDescriptor( + name="org.example.Foo", + display_name="Foo", + description="A recipe.", + tags=[], + estimated_effort_per_occurrence=0, + options=[], + data_tables=[], + recipe_list=[], + ) + + result = _recipe_descriptor_to_dict(descriptor) + + for key in ("tags", "options", "preconditions", "recipeList", + "dataTables", "maintainers", "contributors", "examples"): + assert key in result, f"missing key: {key}" + assert result[key] == [], f"{key} should be empty list, got {result[key]!r}" From e02fd118596cbadb5e81717e680d412007d7beed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Schn=C3=A9ider?= Date: Fri, 1 May 2026 17:21:24 -0400 Subject: [PATCH 2/4] Update recipe.ts --- rewrite-javascript/rewrite/src/recipe.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rewrite-javascript/rewrite/src/recipe.ts b/rewrite-javascript/rewrite/src/recipe.ts index 78dd1096a02..cb2cc059f65 100644 --- a/rewrite-javascript/rewrite/src/recipe.ts +++ b/rewrite-javascript/rewrite/src/recipe.ts @@ -89,11 +89,6 @@ export abstract class Recipe { async descriptor(): Promise { const optionsRecord: Record = (this as any).constructor[OPTIONS_KEY] || {} - // Java's `RecipeDescriptor.getXxx()` getters for collection-valued - // fields are treated as never-null by callers (matching what - // `Recipe.getDescriptor()` upholds locally). Always emit the - // collection keys, even when empty, so Jackson on the Java side - // never leaves them null. return { name: this.name, displayName: this.displayName, From 7190830e19b940e7ff13fd29a07841d712768524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Schn=C3=A9ider?= Date: Fri, 1 May 2026 17:21:58 -0400 Subject: [PATCH 3/4] Update server.py --- rewrite-python/rewrite/src/rewrite/rpc/server.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/rewrite-python/rewrite/src/rewrite/rpc/server.py b/rewrite-python/rewrite/src/rewrite/rpc/server.py index c8156e7dae2..b64302471ef 100644 --- a/rewrite-python/rewrite/src/rewrite/rpc/server.py +++ b/rewrite-python/rewrite/src/rewrite/rpc/server.py @@ -878,13 +878,7 @@ def _category_descriptor_to_dict(descriptor) -> dict: def _recipe_descriptor_to_dict(descriptor) -> dict: - """Convert a RecipeDescriptor to a dict for JSON serialization. - - Java's `RecipeDescriptor.getXxx()` getters for collection-valued fields - are treated as never-null by callers (matching what `Recipe.getDescriptor()` - upholds locally). Always emit the collection keys, even when empty, so - Jackson on the Java side never leaves them null. - """ + """Convert a RecipeDescriptor to a dict for JSON serialization.""" return { 'name': descriptor.name, 'displayName': descriptor.display_name, From 66ca21958b160b8fbd46bb9cea25d010a216baa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Schn=C3=A9ider?= Date: Fri, 1 May 2026 17:22:15 -0400 Subject: [PATCH 4/4] Update test_server.py --- rewrite-python/rewrite/tests/rpc/test_server.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rewrite-python/rewrite/tests/rpc/test_server.py b/rewrite-python/rewrite/tests/rpc/test_server.py index 4798c525b6c..cf2ce222f5b 100644 --- a/rewrite-python/rewrite/tests/rpc/test_server.py +++ b/rewrite-python/rewrite/tests/rpc/test_server.py @@ -27,10 +27,6 @@ def fake_parse_python_source(source, path="", relative_to=None, ty_clie def test_recipe_descriptor_to_dict_emits_all_collection_keys(): - """Java's RecipeDescriptor.getXxx() collection-valued getters are - treated as never-null by callers. Always emit the collection keys — - including the ones the Python dataclass doesn't model — so Jackson on - the Java side never leaves them null.""" from rewrite.recipe import RecipeDescriptor from rewrite.rpc.server import _recipe_descriptor_to_dict