@@ -170,6 +170,81 @@ describe('strictObject', () => {
170170 } ) ;
171171} ) ;
172172
173+ /**
174+ * The ORDER the message emits its parts in — pinned as order, not as presence.
175+ *
176+ * Every assertion above this block is a `toContain` on one fragment, so all of
177+ * them stayed green while `history` sat in the MIDDLE of the message, between
178+ * "which key is wrong" and "here is the fix". That mattered once #5762 promoted
179+ * `flow-time-relative-descriptor-invalid` to **error**: several consumers render
180+ * a finding on ONE line (`os validate`'s `• where: message`, CI logs, and
181+ * `validateFlowTriggerReadiness`, which flattens the newlines out of the schema's
182+ * own text), and `TimeRelativeTriggerSchema`'s history sentence is 224
183+ * characters — so the author, often an AI, read the front of the line and found
184+ * a sentence about 2026 instead of the key to write (#5955).
185+ *
186+ * Direction A of that issue's ruling: move the sentence to the end. Nothing is
187+ * deleted and nothing is conditional — which is exactly why it needs an ORDER
188+ * pin rather than another presence check. A future edit that folds `history`
189+ * back into the front matter passes every `toContain` in this file; it fails
190+ * here.
191+ */
192+ describe ( 'message order — the fix comes before the history (#5955)' , ( ) => {
193+ const HISTORY = 'Until #4001 these were dropped silently — the widget still rendered.' ;
194+
195+ const messageFor = ( body : Record < string , unknown > ) => {
196+ const r = WidgetSchema . safeParse ( { name : 'x' , ...body } ) ;
197+ expect ( r . success ) . toBe ( false ) ;
198+ return r . error ! . issues [ 0 ] ! . message ;
199+ } ;
200+
201+ it ( 'names the wrong key first, then the rename, then the history' , ( ) => {
202+ const m = messageFor ( { colummSpan : 2 } ) ;
203+ // 1. which key is wrong — and nothing before it
204+ expect ( m . startsWith ( 'Unrecognized key(s) on this widget: `colummSpan`.' ) ) . toBe ( true ) ;
205+ // 2. the fix, immediately after it (this is the whole point of the reorder)
206+ expect ( m ) . toContain ( '`colummSpan`. Did you mean `colummSpan` → `columnSpan`?' ) ;
207+ // 3. the history sentence, verbatim, last — moved, never dropped
208+ expect ( m . endsWith ( ` ${ HISTORY } ` ) ) . toBe ( true ) ;
209+ expect ( m . indexOf ( 'Did you mean' ) ) . toBeLessThan ( m . indexOf ( HISTORY ) ) ;
210+ } ) ;
211+
212+ it ( 'puts a guidance prescription ahead of the history too' , ( ) => {
213+ // The other fix channel. A tombstone/wrong-layer prescription is as
214+ // actionable as a rename, so it cannot sit behind the sentence either.
215+ const m = messageFor ( { span : 2 } ) ;
216+ expect ( m . startsWith ( 'Unrecognized key(s) on this widget: `span`.' ) ) . toBe ( true ) ;
217+ expect ( m ) . toContain ( '\n • `span` was retired in vX. Use `columnSpan`.' ) ;
218+ expect ( m . endsWith ( ` ${ HISTORY } ` ) ) . toBe ( true ) ;
219+ expect ( m . indexOf ( 'was retired in vX' ) ) . toBeLessThan ( m . indexOf ( HISTORY ) ) ;
220+ } ) ;
221+
222+ it ( 'keeps BOTH fix channels ahead of the history in one message' , ( ) => {
223+ const m = messageFor ( { span : 2 , colummSpan : 3 } ) ;
224+ expect ( m . startsWith ( 'Unrecognized key(s) on this widget: `span`, `colummSpan`.' ) ) . toBe ( true ) ;
225+ expect ( m . indexOf ( 'Did you mean' ) ) . toBeLessThan ( m . indexOf ( HISTORY ) ) ;
226+ expect ( m . indexOf ( 'was retired in vX' ) ) . toBeLessThan ( m . indexOf ( HISTORY ) ) ;
227+ expect ( m . endsWith ( ` ${ HISTORY } ` ) ) . toBe ( true ) ;
228+ } ) ;
229+
230+ it ( 'emits the history exactly once, whatever the key count' , ( ) => {
231+ // It is a per-SURFACE sentence, not a per-key one: zod raises a single
232+ // `unrecognized_keys` issue naming every offending key, so the sentence is
233+ // appended to that one message once — the property that makes "last" a
234+ // well-defined position at all.
235+ const m = messageFor ( { colummSpan : 2 , alsoWrong : 3 , andThis : 4 } ) ;
236+ expect ( m . split ( HISTORY ) ) . toHaveLength ( 2 ) ;
237+ } ) ;
238+
239+ it ( 'is unchanged when there is no fix to offer' , ( ) => {
240+ // No rename, no prescription — the sentence follows the key statement
241+ // directly, exactly as it always did. Full-message pin, so any stray
242+ // separator or duplicated clause fails here.
243+ expect ( messageFor ( { nonsense : 1 } ) )
244+ . toBe ( `Unrecognized key(s) on this widget: \`nonsense\`. ${ HISTORY } ` ) ;
245+ } ) ;
246+ } ) ;
247+
173248/**
174249 * Never suggest a key the schema cannot accept.
175250 *
0 commit comments