@@ -146,6 +146,115 @@ describe('lintDataModel — fields & objects', () => {
146146 } ) ;
147147} ) ;
148148
149+ // #6108 — `object/missing-name-field` used to read `titleFormat` (retired by
150+ // ADR-0079, and reported as `title-format-retired` by validate-record-title in
151+ // the very same package) while never reading `nameField` at all. An author who
152+ // followed the platform's own migration advice therefore EARNED a suggestion.
153+ // The fixtures below replicate the downstream control surface measured on
154+ // hotcrm main (6 hits, 4 of them false positives — hotcrm#715 / #1007).
155+ describe ( 'lintDataModel — object/missing-name-field (ADR-0079 title face)' , ( ) => {
156+ const flagged = ( objects : any [ ] ) =>
157+ lintDataModel ( objects )
158+ . filter ( ( i ) => i . rule === 'object/missing-name-field' )
159+ . map ( ( i ) => i . path ) ;
160+
161+ // (a) The false positive this fixes. `contract_number` is deliberately NOT in
162+ // NAME_LIKE_FIELDS, so the only title face is the explicit pointer.
163+ it ( 'accepts an object whose title face is an explicit nameField' , ( ) => {
164+ const issues = lintDataModel ( [
165+ {
166+ name : 'crm_contract' ,
167+ nameField : 'contract_number' ,
168+ fields : { contract_number : { type : 'text' } , amount : { type : 'currency' } } ,
169+ } ,
170+ ] ) ;
171+ expect ( has ( issues , 'object/missing-name-field' ) ) . toBe ( false ) ;
172+ } ) ;
173+
174+ // (b) The true hit must survive: a line item with no title face at all.
175+ it ( 'still suggests a name field for an object with no title face' , ( ) => {
176+ const issues = lintDataModel ( [
177+ {
178+ name : 'crm_quote_line_item' ,
179+ fields : {
180+ quote : { type : 'master_detail' , reference : 'crm_quote' } ,
181+ quantity : { type : 'number' } ,
182+ unit_price : { type : 'currency' } ,
183+ } ,
184+ } ,
185+ ] ) ;
186+ expect ( has ( issues , 'object/missing-name-field' ) ) . toBe ( true ) ;
187+ } ) ;
188+
189+ // (c) The two untouched limbs, isolated from each other: neither fixture
190+ // carries a field name that the other limb would also rescue.
191+ it ( 'leaves the primaryField and name-like limbs unchanged' , ( ) => {
192+ expect (
193+ has (
194+ lintDataModel ( [
195+ { name : 'crm_forecast_period' , primaryField : 'period_key' , fields : { period_key : { type : 'text' } } } ,
196+ ] ) ,
197+ 'object/missing-name-field' ,
198+ ) ,
199+ ) . toBe ( false ) ;
200+ expect (
201+ has (
202+ lintDataModel ( [
203+ { name : 'crm_campaign' , fields : { name : { type : 'text' } , budget : { type : 'currency' } } } ,
204+ ] ) ,
205+ 'object/missing-name-field' ,
206+ ) ,
207+ ) . toBe ( false ) ;
208+ } ) ;
209+
210+ // (d) DELIBERATE FLIP, not a regression: a titleFormat-only object is now
211+ // reported. It has no `nameField`, and ADR-0079 wants exactly this object
212+ // migrated — `validate-record-title` already reports it twice today
213+ // (`title-format-retired` + `title-unresolvable`, pinned in
214+ // packages/lint/src/validate-record-title.test.ts). The two rules used to
215+ // disagree about the same object; now they agree.
216+ it ( 'suggests a name field for a titleFormat-only object (retired key is not a title face)' , ( ) => {
217+ const issues = lintDataModel ( [
218+ {
219+ name : 'crm_pipeline_snapshot' ,
220+ titleFormat : '{issued_on} · {amount}' ,
221+ fields : { issued_on : { type : 'date' } , amount : { type : 'currency' } } ,
222+ } ,
223+ ] ) ;
224+ expect ( has ( issues , 'object/missing-name-field' ) ) . toBe ( true ) ;
225+ } ) ;
226+
227+ // The measured control surface, end to end: the four objects hotcrm declared
228+ // a `nameField` on must fall out, the two line items must stay.
229+ it ( 'reproduces the hotcrm control surface: 6 objects in, only the 2 line items flagged' , ( ) => {
230+ const objects = [
231+ { name : 'crm_campaign_member' , nameField : 'member_number' , fields : { member_number : { type : 'text' } } } ,
232+ { name : 'crm_event_attendee' , nameField : 'attendee_number' , fields : { attendee_number : { type : 'text' } } } ,
233+ { name : 'crm_contract' , nameField : 'contract_number' , fields : { contract_number : { type : 'text' } } } ,
234+ { name : 'crm_forecast' , nameField : 'display_title' , fields : { display_title : { type : 'text' } } } ,
235+ { name : 'crm_opportunity_line_item' , fields : { quantity : { type : 'number' } } } ,
236+ { name : 'crm_quote_line_item' , fields : { quantity : { type : 'number' } } } ,
237+ ] ;
238+ expect ( flagged ( objects ) ) . toEqual ( [ 'objects[4].fields' , 'objects[5].fields' ] ) ;
239+ } ) ;
240+
241+ // The suggestion must name the canonical pointer — an author who reads it
242+ // and reaches for `titleFormat` lands straight back in the contradiction.
243+ // It must equally NOT name `primaryField`: that key is declared nowhere in
244+ // `packages/spec`, so `ObjectSchema.create()` rejects it (#6326). The
245+ // predicate still reads the limb; the diagnostic must not advertise it.
246+ it ( 'steers the author to nameField, and names no key the schema rejects' , ( ) => {
247+ const issue = lintDataModel ( [
248+ { name : 'crm_quote_line_item' , fields : { quantity : { type : 'number' } } } ,
249+ ] ) . find ( ( i ) => i . rule === 'object/missing-name-field' ) ;
250+ expect ( issue ?. severity ) . toBe ( 'suggestion' ) ;
251+ expect ( issue ?. message ) . toContain ( 'nameField' ) ;
252+ expect ( issue ?. message ) . not . toContain ( 'primaryField' ) ;
253+ expect ( issue ?. fix ) . toContain ( 'ADR-0079' ) ;
254+ expect ( issue ?. fix ) . toContain ( 'titleFormat' ) ;
255+ } ) ;
256+ } ) ;
257+
149258describe ( 'lintConfig integration' , ( ) => {
150259 it ( 'a clean invoice/line model produces no data-model errors or warnings' , ( ) => {
151260 const issues = lintConfig ( {
0 commit comments