88 FLOW_DRAFT_STATUS_AMBIGUOUS ,
99 FLOW_TRIGGER_UNKNOWN_EVENT ,
1010 FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID ,
11+ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
1112} from './validate-flow-trigger-readiness.js' ;
1213
1314function recordFlow ( overrides : Record < string , unknown > = { } ) {
@@ -353,11 +354,17 @@ describe('validateFlowTriggerReadiness', () => {
353354 it ( 'says nothing about a non-object timeRelative — the engine does not route it here' , ( ) => {
354355 // `AutomationEngine`'s trigger resolution requires `typeof … === 'object'`,
355356 // so `timeRelative: 'daily'` never reaches the time-relative trigger and no
356- // descriptor verdict applies to it. Whatever that flow's defect is, it is
357- // not this rule's, and guessing here would make the rule speak for flows
358- // the engine hands somewhere else.
357+ // descriptor SHAPE verdict applies to it. Guessing here would make this rule
358+ // speak for flows the engine hands somewhere else.
359+ //
360+ // #5647 answered the question this test used to leave open ("whatever that
361+ // flow's defect is, it is not this rule's") — it is 1e's, a different id.
362+ // So the assertion is now two-sided: the shape rule stays out, AND the
363+ // unroutable rule steps in. One-sided, it would have kept passing even if
364+ // the scalar had gone back to being reported by nobody at all.
359365 const findings = validateFlowTriggerReadiness ( timeRelativeStack ( 'daily' ) ) ;
360366 expect ( findings . some ( ( f ) => f . rule === FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID ) ) . toBe ( false ) ;
367+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ] ) ;
361368 } ) ;
362369
363370 it ( 'is inert on flows that declare no timeRelative at all' , ( ) => {
@@ -377,6 +384,229 @@ describe('validateFlowTriggerReadiness', () => {
377384 FLOW_DRAFT_STATUS_AMBIGUOUS ,
378385 ] ) ;
379386 } ) ;
387+
388+ // ── #5647 — the descriptor is not an OBJECT ─────────────────────────────
389+ //
390+ // The complement of everything above, and the case with no runtime channel
391+ // at all: `timeRelative: 'daily'` is not routed by the engine, so the
392+ // trigger never safeParses it, so there is not even the one bind-time warn
393+ // that the object-but-invalid case gets. Verified against `origin/main`
394+ // before this rule existed: `validateFlowTriggerReadiness` and
395+ // `lintFlowPatterns` both returned `[]`, `FlowSchema.safeParse` ACCEPTED the
396+ // scalar (a node `config` is an open slot, ADR-0018), and `os validate` on a
397+ // real example app printed byte-identical output with and without it.
398+ describe ( 'config.timeRelative is not an object (#5647)' , ( ) => {
399+ /** A start node with nothing but the offending descriptor — the issue's flow. */
400+ function scalarOnlyStack ( timeRelative : unknown ) {
401+ const stack = timeRelativeStack ( timeRelative ) ;
402+ // `autolaunched`, so `type: 'schedule'` is not silently supplying a
403+ // trigger the engine would fall back to. This is the never-fires case.
404+ ( stack . flows [ 0 ] as Record < string , unknown > ) . type = 'autolaunched' ;
405+ return stack ;
406+ }
407+
408+ it ( 'flags the scalar from the issue, and names the value, the type and the consequence' , ( ) => {
409+ const findings = validateFlowTriggerReadiness ( scalarOnlyStack ( 'daily' ) ) ;
410+ expect ( findings ) . toHaveLength ( 1 ) ;
411+ const [ f ] = findings ;
412+ expect ( f . rule ) . toBe ( FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ) ;
413+ expect ( f . severity ) . toBe ( 'warning' ) ;
414+ expect ( f . path ) . toBe ( 'flows[0].nodes[0].config.timeRelative' ) ;
415+ expect ( f . where ) . toBe ( 'flow "task_due_reminder" › start node' ) ;
416+ // The value the author wrote AND why it is not a descriptor.
417+ expect ( f . message ) . toContain ( '"daily"' ) ;
418+ expect ( f . message ) . toContain ( '(a string)' ) ;
419+ expect ( f . message ) . toContain ( 'config.timeRelative' ) ;
420+ // The consequence chain, which is the whole reason this is not a nit:
421+ // never routed → sweep never installed → this flow never fires at all,
422+ // and no layer says so.
423+ expect ( f . message ) . toMatch ( / n e v e r r o u t e d / ) ;
424+ expect ( f . message ) . toMatch ( / s w e e p i s n e v e r \s + i n s t a l l e d / ) ;
425+ expect ( f . message ) . toMatch ( / b i n d s t o N O T H I N G a n d \s + n e v e r f i r e s / ) ;
426+ expect ( f . message ) . toMatch ( / z e r o d i a g n o s t i c s a t a n y l a y e r / ) ;
427+ // Single-line, like every other finding this rule emits (the CLI prints
428+ // `• where: message` on one line).
429+ expect ( f . message ) . not . toContain ( '\n' ) ;
430+ expect ( f . hint ) . not . toContain ( '\n' ) ;
431+ } ) ;
432+
433+ it ( "names the cadence-vs-descriptor confusion the scalar actually is" , ( ) => {
434+ // The reason `'daily'` is the specimen: the author has fused "how often
435+ // the sweep runs" with "which records it sweeps". The hint has to
436+ // separate them or it does not help — the cadence key is a SIBLING.
437+ const [ f ] = validateFlowTriggerReadiness ( scalarOnlyStack ( 'daily' ) ) ;
438+ expect ( f . hint ) . toContain ( 'config.schedule' ) ;
439+ expect ( f . hint ) . toMatch ( / s i b l i n g / ) ;
440+ expect ( f . hint ) . toContain ( 'dateField' ) ;
441+ expect ( f . hint ) . toMatch ( / w i t h i n D a y s \| o f f s e t D a y s / ) ;
442+ expect ( f . hint ) . toContain ( 'TimeRelativeTriggerSchema' ) ;
443+ } ) ;
444+
445+ it ( 'flags every non-object shape, each rendered as itself' , ( ) => {
446+ const cases : Array < [ unknown , string ] > = [
447+ [ 'daily' , '"daily" (a string)' ] ,
448+ [ '' , '"" (a string)' ] ,
449+ [ 7 , '7 (a number)' ] ,
450+ [ 0 , '0 (a number)' ] ,
451+ [ true , 'true (a boolean)' ] ,
452+ [ false , 'false (a boolean)' ] ,
453+ ] ;
454+ for ( const [ value , rendered ] of cases ) {
455+ const findings = validateFlowTriggerReadiness ( scalarOnlyStack ( value ) ) ;
456+ expect ( findings . map ( ( f ) => f . rule ) , rendered ) . toEqual ( [
457+ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
458+ ] ) ;
459+ expect ( findings [ 0 ] . message , rendered ) . toContain ( rendered ) ;
460+ }
461+ } ) ;
462+
463+ it ( 'reports a function or symbol without claiming it is undefined' , ( ) => {
464+ // `JSON.stringify` returns the VALUE `undefined` for both, so a naive
465+ // renderer would print a message about a value being "undefined" when it
466+ // is very much present. Only reachable from a TS-authored stack, but a
467+ // diagnostic that misreports its own subject is worse than a vague one.
468+ for ( const [ value , rendered ] of [
469+ [ ( ) => 'daily' , 'a function' ] ,
470+ [ Symbol ( 'daily' ) , 'a symbol' ] ,
471+ ] as Array < [ unknown , string ] > ) {
472+ const findings = validateFlowTriggerReadiness ( scalarOnlyStack ( value ) ) ;
473+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ] ) ;
474+ expect ( findings [ 0 ] . message ) . toContain ( rendered ) ;
475+ expect ( findings [ 0 ] . message ) . not . toContain ( 'undefined' ) ;
476+ }
477+ } ) ;
478+
479+ it ( 'says the flow still binds — not that it never fires — when a sibling trigger exists' , ( ) => {
480+ // Truthfulness of the consequence clause. `resolveTriggerBinding` falls
481+ // THROUGH the time-relative branch for a scalar and keeps going, so a
482+ // start node that also declares a trigger the engine recognizes does bind
483+ // and does fire — on that trigger's terms, with the descriptor silently
484+ // dropped (once per firing, no record on the context). Claiming "never
485+ // fires" about such a flow would be a false diagnostic.
486+ const withSchedule = scalarOnlyStack ( 'daily' ) ;
487+ (
488+ ( withSchedule . flows [ 0 ] as Record < string , unknown > ) . nodes as Array < Record < string , unknown > >
489+ ) [ 0 ] . config = { timeRelative : 'daily' , schedule : { type : 'cron' , expression : '0 8 * * *' } } ;
490+ const [ f ] = validateFlowTriggerReadiness ( withSchedule ) ;
491+ expect ( f . rule ) . toBe ( FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ) ;
492+ expect ( f . message ) . toContain ( 'config.schedule' ) ;
493+ expect ( f . message ) . toMatch ( / s i l e n t l y D R O P P E D / ) ;
494+ expect ( f . message ) . not . toMatch ( / n e v e r f i r e s / ) ;
495+ // …and the certain half is still stated.
496+ expect ( f . message ) . toMatch ( / n e v e r r o u t e d / ) ;
497+ } ) ;
498+
499+ it ( 'names each fallback the engine would actually reach' , ( ) => {
500+ const fallbackOf = ( config : Record < string , unknown > , flowPatch : Record < string , unknown > = { } ) => {
501+ const stack = scalarOnlyStack ( 'daily' ) ;
502+ const flow = stack . flows [ 0 ] as Record < string , unknown > ;
503+ Object . assign ( flow , flowPatch ) ;
504+ ( flow . nodes as Array < Record < string , unknown > > ) [ 0 ] . config = {
505+ timeRelative : 'daily' ,
506+ ...config ,
507+ } ;
508+ return validateFlowTriggerReadiness ( stack ) . find (
509+ ( f ) => f . rule === FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
510+ ) ! . message ;
511+ } ;
512+ // Same order as `resolveTriggerBinding` checks them.
513+ expect ( fallbackOf ( { triggerType : 'record-after-update' , objectName : 'task' } ) )
514+ . toContain ( 'record-change trigger' ) ;
515+ expect ( fallbackOf ( { triggerType : [ 'record-after-create' ] , objectName : 'task' } ) )
516+ . toContain ( 'record-change trigger' ) ;
517+ expect ( fallbackOf ( { schedule : { type : 'cron' , expression : '0 8 * * *' } } ) )
518+ . toContain ( 'config.schedule' ) ;
519+ expect ( fallbackOf ( { } , { type : 'schedule' } ) ) . toContain ( 'config.schedule' ) ;
520+ expect ( fallbackOf ( { triggerType : 'api' } ) ) . toContain ( 'api trigger' ) ;
521+ expect ( fallbackOf ( { } , { type : 'api' } ) ) . toContain ( 'api trigger' ) ;
522+ } ) ;
523+
524+ it ( 'partitions the key with the shape rule — never both, never neither' , ( ) => {
525+ // The two ids split the non-null values of `config.timeRelative` along the
526+ // ENGINE's routing predicate, so exactly one can speak about any given
527+ // descriptor. This is the assertion that keeps 1e from becoming a second
528+ // opinion on flows 1b-ii already covers (and vice versa).
529+ const routed : unknown [ ] = [
530+ { object : 'task' , dateField : 'due_at' , withinDays : 3 } , // valid → neither
531+ { object : 'task' , field : 'due_at' } , // invalid object → INVALID
532+ [ { object : 'task' } ] , // array is typeof 'object' → INVALID
533+ new Date ( '2026-01-01T00:00:00Z' ) , // Date is typeof 'object' → INVALID
534+ ] ;
535+ const unrouted : unknown [ ] = [ 'daily' , 7 , true , '' ] ;
536+
537+ for ( const v of routed ) {
538+ const rules = validateFlowTriggerReadiness ( scalarOnlyStack ( v ) ) . map ( ( f ) => f . rule ) ;
539+ expect ( rules , `routed: ${ String ( v ) } ` ) . not . toContain (
540+ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
541+ ) ;
542+ }
543+ for ( const v of unrouted ) {
544+ const rules = validateFlowTriggerReadiness ( scalarOnlyStack ( v ) ) . map ( ( f ) => f . rule ) ;
545+ expect ( rules , `unrouted: ${ String ( v ) } ` ) . toContain (
546+ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
547+ ) ;
548+ expect ( rules , `unrouted: ${ String ( v ) } ` ) . not . toContain (
549+ FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID ,
550+ ) ;
551+ }
552+ } ) ;
553+
554+ it ( 'is silent when the key is absent or null — those declare no descriptor at all' , ( ) => {
555+ // `null` is on the far side of the engine's `!= null` too, so it is not
556+ // "a descriptor of the wrong type" — it is no descriptor. A flow with no
557+ // trigger at all is a different (pre-existing) gap, not this rule's.
558+ for ( const v of [ null , undefined ] ) {
559+ const stack = scalarOnlyStack ( v ) ;
560+ expect (
561+ validateFlowTriggerReadiness ( stack ) . map ( ( f ) => f . rule ) ,
562+ `${ String ( v ) } must not be treated as a descriptor` ,
563+ ) . not . toContain ( FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ) ;
564+ }
565+ // The contrast, in the same test and on the same fixture builder. Without
566+ // it this case is a bare negative: it would keep passing if the criterion
567+ // were deleted outright and NOTHING were reported — green because nothing
568+ // is produced rather than because the boundary is where it should be.
569+ expect ( validateFlowTriggerReadiness ( scalarOnlyStack ( '' ) ) . map ( ( f ) => f . rule ) ) . toContain (
570+ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
571+ ) ;
572+ } ) ;
573+
574+ it ( 'leaves `isTimeRelative` — and the two rules it feeds — exactly as they were' , ( ) => {
575+ // The boundary this PR promised not to cross. `isTimeRelative` also feeds
576+ // `isAutoTriggered`, so widening it to cover scalars would have changed
577+ // `flow-draft-status-ambiguous`'s coverage as a side effect. It is not
578+ // widened, so a DRAFT flow whose only trigger key is a scalar still does
579+ // not get the draft warning — deliberately unchanged behaviour, pinned
580+ // here so a later edit to `isTimeRelative` has to come past this test.
581+ const stack = scalarOnlyStack ( 'daily' ) ;
582+ delete ( stack . flows [ 0 ] as Record < string , unknown > ) . status ;
583+ const rules = validateFlowTriggerReadiness ( stack ) . map ( ( f ) => f . rule ) ;
584+ expect ( rules ) . toEqual ( [ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ] ) ;
585+ expect ( rules ) . not . toContain ( FLOW_DRAFT_STATUS_AMBIGUOUS ) ;
586+
587+ // The object-shaped control, to show the omission above is about the
588+ // scalar and not about the draft rule having stopped working.
589+ const objectStack = timeRelativeStack ( { object : 'task' , dateField : 'due_at' , withinDays : 3 } ) ;
590+ delete ( objectStack . flows [ 0 ] as Record < string , unknown > ) . status ;
591+ expect ( validateFlowTriggerReadiness ( objectStack ) . map ( ( f ) => f . rule ) ) . toEqual ( [
592+ FLOW_DRAFT_STATUS_AMBIGUOUS ,
593+ ] ) ;
594+ } ) ;
595+
596+ it ( 'is reachable from the published barrel under its own value' , ( ) => {
597+ // The id lands in every finding's `f.rule` and therefore in
598+ // `os lint --json` and `suppressWarnings: ['<id>']`, so a consumer needs
599+ // the constant rather than the literal (#5648). The package-wide gate in
600+ // `rule-id-barrel-exports.test.ts` proves this for every id; this line is
601+ // the local statement of the same fact.
602+ expect ( FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ) . toBe (
603+ 'flow-time-relative-descriptor-unroutable' ,
604+ ) ;
605+ expect ( FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ) . not . toBe (
606+ FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID ,
607+ ) ;
608+ } ) ;
609+ } ) ;
380610 } ) ;
381611
382612 it ( 'passes the record-after-write (create-OR-update) token (#3427)' , ( ) => {
0 commit comments