Skip to content

Commit 06fc07a

Browse files
os-zhuangclaude
andauthored
fix(lint): report a config.timeRelative that is not a descriptor object (#5647) (#5758)
A flow start node whose `config.timeRelative` held a scalar — `timeRelative: 'daily'`, from fusing the sweep's cadence with its descriptor — was accepted in complete silence at every layer. Verified against origin/main before writing the rule: `FlowSchema.safeParse` and `defineFlow` ACCEPT the scalar (a node `config` is an open slot, ADR-0018, so no outer gate looks inside), `validateFlowTriggerReadiness` and `lintFlowPatterns` both returned `[]`, and `os validate` on a real example app printed byte-identical output with and without it. The engine routes a flow to the time-relative sweep only when `typeof config.timeRelative === 'object'`, so the scalar falls through that branch; with no other trigger key on the node `resolveTriggerBinding` returns undefined and `activateFlowTrigger` returns silently. Not one diagnostic anywhere — not even the single bind-time warn an object-but-unparseable descriptor gets, because the trigger is never handed the flow at all. That is the same "folds to no trigger, invisible everywhere" shape #3481 found for a non-string `triggerType`, one key over. `flow-time-relative-descriptor-unroutable` (warning) reports it at authoring time with the value, its type and the consequence, plus a hint that separates the two fused concepts: the descriptor says WHICH records to sweep, while HOW OFTEN is the sibling key `config.schedule`. A separate criterion, not a widening of #5496's `flow-time-relative-descriptor- invalid`. Widening was rejected because `isTimeRelative` also feeds `isAutoTriggered`, so it would have moved two already-published rules' coverage as a side effect of adding a third. The two ids instead partition the key's non-null values along the engine's own routing predicate — a value the engine routes gets the schema's verdict, a value it routes nowhere gets this one, and never both. Arrays and `Date` are `typeof 'object'`, so they stay with the shape rule, which already reports them off the schema's own words. The consequence clause is computed per flow rather than asserted uniformly: a start node that also declares a trigger the engine recognizes DOES bind and fire — on that trigger's terms, with the descriptor silently dropped — and claiming "never fires" about such a flow would be a false diagnostic. Nothing is made tolerant: a scalar is still not a descriptor, and runtime behaviour is unchanged. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b26699 commit 06fc07a

4 files changed

Lines changed: 387 additions & 4 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`os validate` now reports a `config.timeRelative` that is not a descriptor object
6+
7+
A flow start node whose `config.timeRelative` held a scalar — `timeRelative: 'daily'`
8+
is the natural mistake, from fusing the sweep's **cadence** with its **descriptor** —
9+
was accepted in complete silence at every layer. The node `config` slot is open by
10+
design (ADR-0018) so the schema parsed it; the engine routes a flow to the
11+
time-relative sweep only when `config.timeRelative` is an object, so the flow fell
12+
through that branch and, with no other trigger key on the node, bound to nothing and
13+
never fired. Not one diagnostic was produced anywhere — not even the single bind-time
14+
warn that an object-but-unparseable descriptor gets, because the trigger was never
15+
handed the flow at all.
16+
17+
A new authoring rule, `flow-time-relative-descriptor-unroutable` (warning), reports it
18+
at authoring time with the value, its type, and the consequence — and a hint that
19+
separates the two fused concepts: the descriptor says WHICH records to sweep
20+
(`{ object, dateField, and exactly one of withinDays | offsetDays }`), while HOW OFTEN
21+
is the sibling key `config.schedule`.
22+
23+
Nothing is made tolerant: a scalar is still not a descriptor and the runtime's
24+
behaviour is unchanged. The rule is a separate criterion from
25+
`flow-time-relative-descriptor-invalid` (#5496) rather than a widening of it, and the
26+
two partition the key along the engine's own routing predicate — a value the engine
27+
routes gets the schema's verdict, a value it routes nowhere gets this one, and never
28+
both. Arrays and `Date` are `typeof 'object'`, so they stay with the shape rule.

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export {
5757
FLOW_DRAFT_STATUS_AMBIGUOUS,
5858
FLOW_TRIGGER_UNKNOWN_EVENT,
5959
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
60+
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
6061
} from './validate-flow-trigger-readiness.js';
6162
export type {
6263
FlowTriggerReadinessFinding,

‎packages/lint/src/validate-flow-trigger-readiness.test.ts‎

Lines changed: 233 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
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

1314
function 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(/never routed/);
424+
expect(f.message).toMatch(/sweep is never\s+installed/);
425+
expect(f.message).toMatch(/binds to NOTHING and\s+never fires/);
426+
expect(f.message).toMatch(/zero diagnostics at any layer/);
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(/sibling/);
440+
expect(f.hint).toContain('dateField');
441+
expect(f.hint).toMatch(/withinDays \| offsetDays/);
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(/silently DROPPED/);
494+
expect(f.message).not.toMatch(/never fires/);
495+
// …and the certain half is still stated.
496+
expect(f.message).toMatch(/never routed/);
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

Comments
 (0)