Skip to content

Commit f7fa565

Browse files
authored
fix: rename prohibited member names to avoid JSII conflicts (#1841)
Fixes #1833 ## Why When JSON schemas contain property names like `build`, `equals`, or `hashcode`, the generated TypeScript code fails to compile. These names conflict with synthetic declarations that JSII adds to structs in target languages (Java, Python, etc.). For example, a CloudFormation resource with a `build` property would generate code that clashes with the `build()` method JSII creates for struct builders. ## Solution Property names matching prohibited JSII member names are now automatically renamed by appending an underscore suffix (e.g., `build` → `build_`). The `toJson` function correctly maps these back to the original schema names during serialization. --- ### Ask Yourself - [x] Have you reviewed the [contribution guide](https://github.com/cdklabs/json2jsii/blob/main/CONTRIBUTING.md)? - [x] Have you reviewed the [breaking changes guide](https://github.com/cdklabs/json2jsii/blob/main/CONTRIBUTING.md#breaking-changes)? - This is not breaking since the previously generated code does not compile with `jsii`.
1 parent 11f5c74 commit f7fa565

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

src/type-generator.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ const DEFINITIONS_LEGACY_PREFIX = '#/definitions/';
1111
const DEFINITIONS_PREFIX = '#/$defs/';
1212
const DEFAULT_RENDER_TYPE_NAME = (s: string) => s.split('.').map(x => pascalCase(x)).join('');
1313

14+
/**
15+
* JSII synthetic declarations that cannot be used as property names.
16+
* These are reserved method names in JSII structs across target languages.
17+
*
18+
* @see https://github.com/aws/jsii-compiler/blob/0f142651abac9eff35dfbd74c141d5ff058b2a8a/src/assembler.ts#L3193
19+
*/
20+
const PROHIBITED_MEMBER_NAMES = ['build', 'equals', 'hashcode'];
21+
1422
/**
1523
* Available opt-in schema transformations
1624
*/
@@ -611,6 +619,11 @@ export class TypeGenerator {
611619
// convert the name to camel case so it's compatible with JSII
612620
name = camelCase(name);
613621

622+
// if the name conflicts with a prohibited member name, append underscore
623+
if (PROHIBITED_MEMBER_NAMES.includes(name.toLowerCase())) {
624+
name = name + '_';
625+
}
626+
614627
const propertyFqn = this.propertyFqn(structFqn, name);
615628

616629
if (this.emittedProperties.has(propertyFqn)) {

test/prohibited-names.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { JSONSchema4 } from 'json-schema';
2+
import { TypeGenerator } from '../src';
3+
4+
describe('prohibited member names', () => {
5+
describe('unit tests', () => {
6+
test("'build' property becomes 'build_'", () => {
7+
const schema: JSONSchema4 = {
8+
type: 'object',
9+
properties: {
10+
build: { type: 'string' },
11+
},
12+
};
13+
14+
const gen = TypeGenerator.forStruct('TestStruct', schema);
15+
const output = gen.render();
16+
17+
expect(output).toContain('readonly build_?:');
18+
expect(output).not.toMatch(/readonly build\?:/);
19+
});
20+
21+
test("'equals' property becomes 'equals_'", () => {
22+
const schema: JSONSchema4 = {
23+
type: 'object',
24+
properties: {
25+
equals: { type: 'boolean' },
26+
},
27+
};
28+
29+
const gen = TypeGenerator.forStruct('TestStruct', schema);
30+
const output = gen.render();
31+
32+
expect(output).toContain('readonly equals_?:');
33+
expect(output).not.toMatch(/readonly equals\?:/);
34+
});
35+
36+
test("'hashcode' property becomes 'hashcode_'", () => {
37+
const schema: JSONSchema4 = {
38+
type: 'object',
39+
properties: {
40+
hashcode: { type: 'number' },
41+
},
42+
};
43+
44+
const gen = TypeGenerator.forStruct('TestStruct', schema);
45+
const output = gen.render();
46+
47+
expect(output).toContain('readonly hashcode_?:');
48+
expect(output).not.toMatch(/readonly hashcode\?:/);
49+
});
50+
51+
test('non-prohibited names remain unchanged', () => {
52+
const schema: JSONSchema4 = {
53+
type: 'object',
54+
properties: {
55+
name: { type: 'string' },
56+
value: { type: 'number' },
57+
enabled: { type: 'boolean' },
58+
},
59+
};
60+
61+
const gen = TypeGenerator.forStruct('TestStruct', schema);
62+
const output = gen.render();
63+
64+
expect(output).toContain('readonly name?:');
65+
expect(output).toContain('readonly value?:');
66+
expect(output).toContain('readonly enabled?:');
67+
});
68+
69+
test('toJson correctly maps renamed properties to original names', () => {
70+
const schema: JSONSchema4 = {
71+
type: 'object',
72+
properties: {
73+
build: { type: 'string' },
74+
name: { type: 'string' },
75+
},
76+
};
77+
78+
const gen = TypeGenerator.forStruct('TestStruct', schema);
79+
const output = gen.render();
80+
81+
// The toJson function should map build_ back to 'build'
82+
expect(output).toContain("'build': obj.build_");
83+
expect(output).toContain("'name': obj.name");
84+
});
85+
86+
test('prohibited names are case-insensitive', () => {
87+
const schema: JSONSchema4 = {
88+
type: 'object',
89+
properties: {
90+
BUILD: { type: 'string' },
91+
Equals: { type: 'boolean' },
92+
HashCode: { type: 'number' },
93+
},
94+
};
95+
96+
const gen = TypeGenerator.forStruct('TestStruct', schema);
97+
const output = gen.render();
98+
99+
// After camelCase conversion, these become 'build', 'equals', 'hashCode'
100+
// which should be renamed to 'build_', 'equals_', 'hashCode_'
101+
expect(output).toContain('readonly build_?:');
102+
expect(output).toContain('readonly equals_?:');
103+
expect(output).toContain('readonly hashCode_?:');
104+
});
105+
});
106+
});
107+
108+
109+
import * as fc from 'fast-check';
110+
111+
/**
112+
* Property-based tests for prohibited name handling
113+
* Feature: reserved-word-property-names
114+
*/
115+
describe('prohibited member names - property tests', () => {
116+
const PROHIBITED_NAMES = ['build', 'equals', 'hashcode'];
117+
118+
/**
119+
* Property 1: Prohibited names get underscore suffix
120+
* Validates: Requirements 1.1
121+
*
122+
* For any property name that, after camelCase conversion, matches a prohibited
123+
* member name (case-insensitive), the emitted TypeScript property name SHALL
124+
* have an underscore suffix appended.
125+
*/
126+
test('Property 1: Prohibited names get underscore suffix', () => {
127+
fc.assert(
128+
fc.property(
129+
fc.constantFrom(...PROHIBITED_NAMES),
130+
(prohibitedName) => {
131+
const schema: JSONSchema4 = {
132+
type: 'object',
133+
properties: {
134+
[prohibitedName]: { type: 'string' },
135+
},
136+
};
137+
138+
const gen = TypeGenerator.forStruct('TestStruct', schema);
139+
const output = gen.render();
140+
141+
// The property should be renamed with underscore suffix
142+
expect(output).toContain(`readonly ${prohibitedName}_?:`);
143+
// The original name should not appear as a property
144+
expect(output).not.toMatch(new RegExp(`readonly ${prohibitedName}\\?:`));
145+
},
146+
),
147+
{ numRuns: 100 },
148+
);
149+
});
150+
});
151+
152+
153+
describe('prohibited member names - property tests (continued)', () => {
154+
const PROHIBITED_NAMES = ['build', 'equals', 'hashcode'];
155+
156+
/**
157+
* Property 2: Non-prohibited names remain unchanged
158+
* Validates: Requirements 1.2
159+
*
160+
* For any property name that, after camelCase conversion, does NOT match any
161+
* prohibited member name, the emitted TypeScript property name SHALL equal
162+
* the camelCase-converted name.
163+
*/
164+
test('Property 2: Non-prohibited names remain unchanged', () => {
165+
// Generate simple lowercase alphabetic property names that are not prohibited
166+
// Using lowercase-only alphabetic names avoids camelCase conversion complexity
167+
const validPropertyNameArb = fc.string({ minLength: 1, maxLength: 15 })
168+
.filter(s => /^[a-z]+$/.test(s))
169+
.filter(s => !PROHIBITED_NAMES.includes(s));
170+
171+
fc.assert(
172+
fc.property(
173+
validPropertyNameArb,
174+
(propertyName) => {
175+
const schema: JSONSchema4 = {
176+
type: 'object',
177+
properties: {
178+
[propertyName]: { type: 'string' },
179+
},
180+
};
181+
182+
const gen = TypeGenerator.forStruct('TestStruct', schema);
183+
const output = gen.render();
184+
185+
// For lowercase-only alphabetic names, camelCase conversion is identity
186+
// The property should NOT have underscore suffix
187+
expect(output).not.toContain(`readonly ${propertyName}_?:`);
188+
// The property should appear with its original name
189+
expect(output).toContain(`readonly ${propertyName}?:`);
190+
},
191+
),
192+
{ numRuns: 100 },
193+
);
194+
});
195+
});

0 commit comments

Comments
 (0)