@contractkit/plugin-typescript 0.17.3 → 0.17.4

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": "@contractkit/plugin-typescript",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -29,8 +29,8 @@
29
29
  "@contractkit/core": "0.13.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@repo/config-eslint": "0.3.1",
33
- "@repo/config-typescript": "0.1.0"
32
+ "@repo/config-typescript": "0.1.0",
33
+ "@repo/config-eslint": "0.3.1"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -55,8 +55,10 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
55
55
  lines.push('');
56
56
  }
57
57
 
58
+ const modelMap = new Map(root.models.map(m => [m.name, m]));
59
+
58
60
  for (const model of topoSortModels(root.models)) {
59
- lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, allModelsWithOutput));
61
+ lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
60
62
  lines.push('');
61
63
  }
62
64
 
@@ -65,7 +67,13 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
65
67
 
66
68
  // ─── Model ─────────────────────────────────────────────────────────────────
67
69
 
68
- function generateModel(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelsWithOutput?: Set<string>): string[] {
70
+ function generateModel(
71
+ model: ModelNode,
72
+ outPath?: string,
73
+ modelsWithInput?: Set<string>,
74
+ modelsWithOutput?: Set<string>,
75
+ modelMap?: Map<string, ModelNode>,
76
+ ): string[] {
69
77
  // Type alias: Name : typeExpression
70
78
  if (model.type) {
71
79
  return generateTypeAlias(model, outPath, modelsWithInput, modelsWithOutput);
@@ -75,7 +83,7 @@ function generateModel(model: ModelNode, outPath?: string, modelsWithInput?: Set
75
83
  // transitively references models that have Input variants (captured in modelsWithInput).
76
84
  const needsInputSplit = model.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(model.name) ?? false);
77
85
 
78
- const lines = needsInputSplit ? generateVisibilityModel(model, outPath, modelsWithInput) : generateSimpleModel(model, outPath);
86
+ const lines = needsInputSplit ? generateVisibilityModel(model, outPath, modelsWithInput, modelMap) : generateSimpleModel(model, outPath, modelMap);
79
87
 
80
88
  if (modelsWithOutput?.has(model.name)) {
81
89
  lines.push('');
@@ -84,6 +92,30 @@ function generateModel(model: ModelNode, outPath?: string, modelsWithInput?: Set
84
92
  return lines;
85
93
  }
86
94
 
95
+ /** Recursively collect every field name defined on `bases` and their ancestors. Used to detect
96
+ * fields that the child re-declares without an explicit `override` keyword — those still need an
97
+ * `Omit<Base, …>` wrap, otherwise the child's narrower/incompatible declaration collides with the
98
+ * inherited one. */
99
+ function collectInheritedFieldNames(bases: string[], modelMap: Map<string, ModelNode>): Set<string> {
100
+ const result = new Set<string>();
101
+ const visit = (name: string): void => {
102
+ const m = modelMap.get(name);
103
+ if (!m || m.type) return;
104
+ for (const f of m.fields) result.add(f.name);
105
+ for (const b of m.bases ?? []) visit(b);
106
+ };
107
+ for (const b of bases) visit(b);
108
+ return result;
109
+ }
110
+
111
+ /** Names of fields the child declaration overrides — explicit `override` plus any field whose
112
+ * name shadows an inherited one. The latter catches single-base redeclarations that omit the
113
+ * `override` keyword (e.g. narrowing `kind: BusinessRoleKind` → `kind: 'employee'`). */
114
+ function computeOverrideNames(model: ModelNode, modelMap?: Map<string, ModelNode>): string[] {
115
+ const inherited = modelMap ? collectInheritedFieldNames(model.bases ?? [], modelMap) : new Set<string>();
116
+ return model.fields.filter(f => f.override || inherited.has(f.name)).map(f => f.name);
117
+ }
118
+
87
119
  function generateComments(model: ModelNode, outPath?: string): string[] {
88
120
  const lines: string[] = [];
89
121
  lines.push('/**');
@@ -113,11 +145,11 @@ function generateTypeAlias(model: ModelNode, outPath?: string, modelsWithInput?:
113
145
  return lines;
114
146
  }
115
147
 
116
- /** Build the `extends` clause for a multi-base model.
117
- * Override-marked field names are wrapped in `Omit<Base, 'name1' | 'name2'>` per base so the
118
- * subclass can legally redeclare them with new types. TypeScript's `Omit<T, K extends keyof any>`
119
- * tolerates omit keys that don't appear on the base, so we omit unconditionally — no need to know
120
- * each base's actual field set. */
148
+ /** Build the `extends` clause for a model.
149
+ * Each entry in `overrideNames` is wrapped in `Omit<Base, 'name1' | 'name2'>` per base so the
150
+ * subclass can legally redeclare those fields with new (possibly incompatible) types.
151
+ * TypeScript's `Omit<T, K extends keyof any>` tolerates omit keys that don't appear on the base,
152
+ * so we apply the same omit list to every base without per-base field-set lookup. */
121
153
  function buildExtendsClause(bases: string[], overrideNames: string[], baseNameResolver: (b: string) => string): string {
122
154
  if (bases.length === 0) return '';
123
155
  if (overrideNames.length === 0) return ` extends ${bases.map(baseNameResolver).join(', ')}`;
@@ -126,12 +158,12 @@ function buildExtendsClause(bases: string[], overrideNames: string[], baseNameRe
126
158
  return ` extends ${wrapped.join(', ')}`;
127
159
  }
128
160
 
129
- function generateSimpleModel(model: ModelNode, outPath?: string): string[] {
161
+ function generateSimpleModel(model: ModelNode, outPath?: string, modelMap?: Map<string, ModelNode>): string[] {
130
162
  const lines: string[] = [];
131
163
  lines.push(...generateComments(model, outPath));
132
164
 
133
165
  const bases = model.bases ?? [];
134
- const overrideNames = model.fields.filter(f => f.override).map(f => f.name);
166
+ const overrideNames = computeOverrideNames(model, modelMap);
135
167
  lines.push(`export interface ${model.name}${buildExtendsClause(bases, overrideNames, b => b)} {`);
136
168
 
137
169
  for (const field of model.fields) {
@@ -142,12 +174,12 @@ function generateSimpleModel(model: ModelNode, outPath?: string): string[] {
142
174
  return lines;
143
175
  }
144
176
 
145
- function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>): string[] {
177
+ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelMap?: Map<string, ModelNode>): string[] {
146
178
  const lines: string[] = [];
147
179
  lines.push(...generateComments(model, outPath));
148
180
 
149
181
  const bases = model.bases ?? [];
150
- const overrideNames = model.fields.filter(f => f.override).map(f => f.name);
182
+ const overrideNames = computeOverrideNames(model, modelMap);
151
183
 
152
184
  // Read type — omit writeonly fields
153
185
  const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
@@ -419,6 +419,44 @@ describe('generatePlainTypes', () => {
419
419
  const output = generatePlainTypes(root);
420
420
  expect(output).toContain("export interface Test5 extends Omit<A, 'a' | 'b'> {");
421
421
  });
422
+
423
+ it('omits implicit single-base redeclarations even without explicit override', () => {
424
+ // BusinessRoleOfficer-style: child redeclares `kind` without `override` keyword,
425
+ // narrowing to an incompatible literal — must wrap base in Omit to compile.
426
+ const root = contractRoot([
427
+ model('Employee', [field('kind', literalType('employee')), field('title', scalarType('string'))]),
428
+ model('Officer', [field('kind', literalType('officer')), field('isControlPerson', scalarType('boolean'))], {
429
+ bases: ['Employee'],
430
+ }),
431
+ ]);
432
+ const output = generatePlainTypes(root);
433
+ expect(output).toContain("export interface Officer extends Omit<Employee, 'kind'> {");
434
+ });
435
+
436
+ it('applies implicit-override Omit to both Read and Input interfaces', () => {
437
+ const root = contractRoot([
438
+ model('Employee', [
439
+ field('id', scalarType('uuid'), { visibility: 'readonly' }),
440
+ field('kind', literalType('employee')),
441
+ ]),
442
+ model('Officer', [field('kind', literalType('officer')), field('isControlPerson', scalarType('boolean'))], {
443
+ bases: ['Employee'],
444
+ }),
445
+ ]);
446
+ const output = generatePlainTypes(root);
447
+ expect(output).toContain("export interface Officer extends Omit<Employee, 'kind'> {");
448
+ expect(output).toContain("export interface OfficerInput extends Omit<EmployeeInput, 'kind'> {");
449
+ });
450
+
451
+ it('detects redeclaration through transitive base chain', () => {
452
+ const root = contractRoot([
453
+ model('Root', [field('kind', scalarType('string'))]),
454
+ model('Mid', [], { bases: ['Root'] }),
455
+ model('Leaf', [field('kind', literalType('leaf'))], { bases: ['Mid'] }),
456
+ ]);
457
+ const output = generatePlainTypes(root);
458
+ expect(output).toContain("export interface Leaf extends Omit<Mid, 'kind'> {");
459
+ });
422
460
  });
423
461
 
424
462
  // ─── Type alias Input variants ────────────────────────────────