Skip to content

Commit e652a77

Browse files
committed
fix(rest,lint): the whole-plus-part collision refusal lists the legal parts too
Ruling item 3 on #20149 says a refusal names the field and lists the legal parts. The rest door emitted the parts sentence only when an unknown target was present, and the lint collision hint carried no part list; both now list the object's compound fields and their parts on every refusal, and the collision pins assert it. The mapping.zod docblock now names the reader of the part set. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
1 parent f21f41d commit e652a77

6 files changed

Lines changed: 24 additions & 11 deletions

File tree

‎packages/lint/src/validate-mapping-target-fields.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ describe('validateMappingTargetFields', () => {
117117
expect(findings[0]).toMatchObject({
118118
rule: MAPPING_TARGET_FIELD_UNKNOWN,
119119
path: 'mappings[0].fieldMapping[1].target',
120-
hint: 'Map the field whole or by its parts, not both.',
120+
// The collision names the field AND lists its legal parts, as every refusal does.
121+
hint: 'Map the field whole or by its parts, not both. A declared part of a compound field is written '
122+
+ 'field.part (mailing_address: street, city, state, postalCode, country, countryCode, formatted).',
121123
});
122124
expect(findings[0].message).toContain(
123125
'writes the compound field "mailing_address" of object "crm_contact" both whole (fieldMapping[0].target) '

‎packages/lint/src/validate-mapping-target-fields.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,11 @@ export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFi
152152
if (misses.length === 0) continue; // clean, or skip 2
153153
const index = indexImportMappingTargets(objectDef);
154154
const known = index?.names ?? new Set<string>();
155-
const compound = index ? [...index.parts] : [];
155+
// The object's compound fields and their legal parts, which EVERY finding's
156+
// hint lists, a collision's included (ruling on #20149, item 3).
157+
const compoundParts = index && index.parts.size > 0
158+
? [...index.parts].map(([field, parts]) => `${field}: ${parts.join(', ')}`).join('; ')
159+
: '';
156160
const mappingName = strName(mapping.name) ?? `#${mi}`;
157161
const where = `mapping "${mappingName}" · object "${objectName}"`;
158162

@@ -169,7 +173,9 @@ export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFi
169173
`"${objectName}" both whole (${miss.wholeAt}) and by its part "${miss.target.slice(dot + 1)}" ` +
170174
`(${miss.path}). One row carries one value for a field, so the two collide, and the import endpoint ` +
171175
`refuses this mapping before any row, on the dry run and the commit alike (INVALID_FIELD).`,
172-
hint: 'Map the field whole or by its parts, not both.',
176+
hint:
177+
'Map the field whole or by its parts, not both. A declared part of a compound field is ' +
178+
`written field.part (${compoundParts}).`,
173179
});
174180
continue;
175181
}
@@ -186,10 +192,7 @@ export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFi
186192
hint:
187193
`Point the target at a field "${objectName}" declares, or at a column the platform provisions ` +
188194
`on it` +
189-
(compound.length > 0
190-
? `, or at a declared part of a compound field as field.part (` +
191-
compound.map(([field, parts]) => `${field}: ${parts.join(', ')}`).join('; ') + ')'
192-
: '') +
195+
(compoundParts ? `, or at a declared part of a compound field as field.part (${compoundParts})` : '') +
193196
`. Addressable names: ${listNames(known)}.`,
194197
});
195198
}

‎packages/rest/src/import-integration.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,8 @@ describe('import route — a declared part of a compound field (#20149)', () =>
855855
expect(res._json.error).toContain(
856856
'(fieldMapping[2].target "mailing_address.street", with the whole field at fieldMapping[1].target)',
857857
);
858+
// …and, like every refusal, it lists the legal parts.
859+
expect(res._json.error).toContain(`or at a declared part of a compound field as field.part (mailing_address: ${PARTS})`);
858860
}
859861
expect(dry._json).toEqual(commit._json);
860862
expect(await engine.find('contact', { where: {} })).toHaveLength(0);

‎packages/rest/src/import-mapping.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,5 +250,7 @@ describe('refuseUnknownMappingTargets — compound-field parts (#20149)', () =>
250250
+ 'with the whole field at fieldMapping[0].target)',
251251
);
252252
expect(r?.error).toContain('refused before any row, on the dry run and the commit alike');
253+
// A collision-only refusal lists the legal parts too (ruling item 3).
254+
expect(r?.error).toContain(`or at a declared part of a compound field as field.part (mailing_address: ${PARTS})`);
253255
});
254256
});

‎packages/rest/src/import-mapping.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ export function refuseUnknownMappingTargets(
171171
+ 'Map the field whole or by its parts, not both.',
172172
);
173173
}
174-
if (unknown.length > 0) sentences.push(`Point each target at a field the object declares${partsHint(objectSchema)}.`);
174+
// Every refusal, a collision included, ends by listing the legal parts
175+
// (ruling on #20149, item 3: the refusal names the field and lists them).
176+
sentences.push(`Point each target at a field the object declares${partsHint(objectSchema)}.`);
175177
return { ok: false, status: 400, code: 'INVALID_FIELD', error: sentences.join(' ') };
176178
}
177179

‎packages/spec/src/data/mapping.zod.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,11 @@ export type TransformType = z.input<typeof TransformType>;
191191
* single `string`. A target may also name a declared PART of a compound
192192
* field, `field.part` (`mailing_address.street`, #20149): the import path
193193
* assembles every part one row maps into that field's one value. Which
194-
* fields are compound, and which parts they declare, is read from the
195-
* field's value schema by `judgeImportMappingTarget`
196-
* (`import-mapping-target.ts`), the one verdict every door asks.
194+
* fields are compound, and which parts they declare, is read from each
195+
* field's value schema when `indexImportMappingTargets` indexes the object
196+
* (its module-private `importTargetPartsOf`, `import-mapping-target.ts`);
197+
* `judgeImportMappingTarget`, the one verdict every door asks, then judges
198+
* a `field.part` target against that set.
197199
* 3. This schema is a {@link strictObject} (#4001): an unknown key THROWS with
198200
* an alias/typo prescription. The other two are plain `z.object` and strip
199201
* silently. Opposite failure modes under one name is exactly how a snippet

0 commit comments

Comments
 (0)