diff --git a/.changeset/lint-flow-trigger-unroutable.md b/.changeset/lint-flow-trigger-unroutable.md new file mode 100644 index 0000000000..df63ca5d15 --- /dev/null +++ b/.changeset/lint-flow-trigger-unroutable.md @@ -0,0 +1,9 @@ +--- +'@objectstack/lint': minor +--- + +新增 gating 规则 `flow-trigger-unroutable`(#6637):`type: 'record_change'` 的 flow 若在 start 节点声明了引擎无法路由的 `triggerType`(如 `onCreate`、`on_update`、`['onCreate']`),现在在 `os lint` / `os validate` / `os build` 与运行时发布闸门上报 `error`。 + +这是 never-fire 家族里最安静的一种失败。引擎的 `resolveTriggerBinding` 只按字面量 `startsWith('record-')` 认领 record-change flow,落空后整条分支链走到底返回 `undefined`,`activateFlowTrigger` 直接 `return`,该 flow 就被当成手动 flow —— 而专门为「悄悄没绑上」而建的 `getTriggerBindingAudit` 调用的是同一个 resolver,会以「manual / screen flow — nothing to bind」跳过它。因此启动告警和 CLI 启动摘要都不会点名,唯一痕迹是 banner 里 flow 总数比 bound 数多一。 + +规则范围刻意收窄到「声明了 `record_change`」的 flow:落空到无绑定本身也正是一个 flow 合法地成为手动 flow 的机制,而 `autolaunched` / `screen` 才是手动 flow 声明的类型,所以这条规则在结构上不可能误伤真正的手动 flow。`triggerType` 完全缺失的情形(同样必死)不在本次范围内,另行处理。 diff --git a/packages/cli/test/authoring-rule-command-parity.test.ts b/packages/cli/test/authoring-rule-command-parity.test.ts index 55c6bda1a7..b197f31be0 100644 --- a/packages/cli/test/authoring-rule-command-parity.test.ts +++ b/packages/cli/test/authoring-rule-command-parity.test.ts @@ -57,7 +57,12 @@ const CASES: ReadonlyArray<{ rule: string; blindTo: readonly AuthoringCommand[]; flows: [{ name: 'parity_flow', label: 'Parity', type: 'record_change', runAs: 'system', status: 'active', nodes: [ - { id: 'start', type: 'start', label: 'Start', config: { objectName: 'parity_task', triggerType: 'onCreate' } }, + // #6637 — was `triggerType: 'onCreate'`, which on a `type: 'record_change'` + // flow the engine routes to no trigger at all. This case's planted defect + // is the approver expression; the trigger token was incidental, and leaving + // a second (now gating) defect in the fixture would let the case pass on a + // finding it is not about. + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'parity_task', triggerType: 'record-after-create' } }, { id: 'appr', type: 'approval', label: 'Approve', config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] } }, { id: 'end', type: 'end', label: 'End' }, ], diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index a3c2154945..5054234383 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -646,10 +646,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // // `gating` since #5762, which reviewed the file's rules as one family and // split them on a single question: is THIS STACK enough to know the flow is - // dead? Three rules answer yes and now emit `error` — a `config.timeRelative` + // dead? Four rules answer yes and emit `error` — a `config.timeRelative` // the spec's own `TimeRelativeTriggerSchema` refuses, one the engine's routing - // predicate cannot route at all, and a `record-*` triggerType outside the - // closed token grammar `triggerTypeToHookEvents` maps. None of those verdicts + // predicate cannot route at all, a `record-*` triggerType outside the + // closed token grammar `triggerTypeToHookEvents` maps, and (#6637) a + // `type: 'record_change'` flow whose triggerType the engine's binding resolver + // routes nowhere, silently demoting it to a manual flow. None of those verdicts // can be changed by installing a package, so there is no reading under which // the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning` // (the object may come from another installed package — a hedge this rule diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 4c1aa1a773..ba82e16df1 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -83,6 +83,7 @@ export { FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + FLOW_TRIGGER_UNROUTABLE, } from './validate-flow-trigger-readiness.js'; export type { FlowTriggerReadinessFinding, diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 0bb289c69f..6070c1f9e3 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -452,7 +452,12 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR- // existence. Pinned so nobody "fixes" the gap by guessing. it('does NOT flag record_change — undecidable at authoring time, caught at run time', () => { const fnds = lintFlowPatterns( - scheduledDataFlow({ flowType: 'record_change', startConfig: { triggerType: 'record_change', objectName: 'invoice' } }), + // #6637 — the token was `'record_change'` (the flow TYPE echoed into the + // trigger slot), which is not an authored trigger token at all: the engine + // routes only `record-*`, so that fixture described a flow that never fires. + // The pin is about a record-change flow, so it spells one — the same token + // the sibling guard below already uses. + scheduledDataFlow({ flowType: 'record_change', startConfig: { triggerType: 'record-after-update', objectName: 'invoice' } }), ); expect(fnds.map((f) => f.rule)).not.toContain(FLOW_RUNAS_UNSCOPED); }); diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 236ba9bafd..5f700fca32 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -9,6 +9,7 @@ import { FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + FLOW_TRIGGER_UNROUTABLE, } from './validate-flow-trigger-readiness.js'; function recordFlow(overrides: Record = {}) { @@ -720,6 +721,198 @@ describe('validateFlowTriggerReadiness', () => { expect(findings.some((f) => f.rule === FLOW_TRIGGER_UNKNOWN_EVENT)).toBe(false); }); + // ── #6637 — a record_change flow the engine routes NOWHERE ─────────────── + // + // The quietest member of the never-fire family. Every case below is built on + // `unroutable()`, which differs from `recordFlow()` in exactly the two ways + // the criterion cares about: `type: 'record_change'` (the declared intent) and + // a start-node `triggerType` outside the `record-` prefix (the contradiction). + // + // The "does NOT flag" block is the load-bearing half. A criterion phrased on + // "resolves to no binding" alone would flag every manual and screen flow in + // the world, so each guard below is a shape the rule must stay silent about, + // paired — per the same fixture — with the one-key mutation that makes it + // speak. A guard whose green could also be produced by a rule that never runs + // is not a guard. + describe('record_change flow with an unroutable triggerType (#6637)', () => { + /** A `type: 'record_change'` flow whose start node carries `config`. */ + const unroutable = (config: Record, flowOverrides: Record = {}) => ({ + objects: [candidateObject], + flows: [ + { + name: 'candidate_hired', + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'app_candidate', ...config } }, + { id: 'end', type: 'end' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...flowOverrides, + }, + ], + }); + + it("flags the issue's specimen — triggerType 'onCreate' on a record_change flow", () => { + const findings = validateFlowTriggerReadiness(unroutable({ triggerType: 'onCreate' })); + expect(findings.map((f) => f.rule)).toEqual([FLOW_TRIGGER_UNROUTABLE]); + expect(findings[0].severity).toBe('error'); + expect(findings[0].message).toContain("'onCreate'"); + expect(findings[0].path).toBe('flows[0].nodes[0].config.triggerType'); + }); + + it('describes the MEASURED failure, not just "never fires"', () => { + // The three engine facts the message is built on — a message that only + // said "never fires" would be indistinguishable from `…-unknown-event`, + // whose flow DOES bind and DOES get a bind-time warn. The distinguishing + // claim is that nothing names this one, and the rule has to make it. + const [f] = validateFlowTriggerReadiness(unroutable({ triggerType: 'onCreate' })); + expect(f.message).toMatch(/record_change/); + expect(f.message).toMatch(/never fires/i); + expect(f.message).toMatch(/audit/i); + // …and it must NOT overclaim: the flow count line does move, so the + // message says "nothing NAMES it" rather than "no signal anywhere". + expect(f.message).toMatch(/count/i); + expect(f.hint).toMatch(/record-\{before,after\}/); + expect(f.hint).toMatch(/autolaunched/); + }); + + it('flags the other unroutable spellings an author reaches for', () => { + for (const tt of ['on_create', 'onUpdate', 'create', 'record_change', 'after_insert', '']) { + const findings = validateFlowTriggerReadiness(unroutable({ triggerType: tt })); + expect(findings.map((f) => f.rule), `triggerType ${JSON.stringify(tt)}`).toEqual([ + FLOW_TRIGGER_UNROUTABLE, + ]); + } + }); + + it('flags a non-record ARRAY on a record_change flow — 1d deliberately does not claim it', () => { + // `['onCreate']` has no `record-` element, so `flow-trigger-unknown-event` + // (1d) stays silent by design and pins that silence in its own test. On an + // `autolaunched` flow that silence is correct; on a `record_change` flow it + // left the loudest possible contradiction unreported. The token is rendered + // as JSON so the author sees the shape they wrote. + const findings = validateFlowTriggerReadiness(unroutable({ triggerType: ['onCreate'] })); + expect(findings.map((f) => f.rule)).toEqual([FLOW_TRIGGER_UNROUTABLE]); + expect(findings[0].message).toContain('["onCreate"]'); + expect(findings.some((f) => f.rule === FLOW_TRIGGER_UNKNOWN_EVENT)).toBe(false); + }); + + describe('does NOT flag (each paired with the mutation that makes it fire)', () => { + it('a canonical record-* token — the whole point of declaring record_change', () => { + expect(validateFlowTriggerReadiness(unroutable({ triggerType: 'record-after-update' }))).toEqual([]); + // Planted bad value on the SAME fixture: the green above is the rule + // judging a good token, not the rule failing to run. + expect( + validateFlowTriggerReadiness(unroutable({ triggerType: 'record_after_update' })).map((f) => f.rule), + ).toEqual([FLOW_TRIGGER_UNROUTABLE]); + }); + + it('an off-grammar record-* token — that is 1c\'s finding, and only 1c\'s', () => { + // The two ids partition the dead tokens on whether the engine routes the + // value, so exactly one of them can ever speak about a given token. + const findings = validateFlowTriggerReadiness(unroutable({ triggerType: 'record-after-updated' })); + expect(findings.map((f) => f.rule)).toEqual([FLOW_TRIGGER_UNKNOWN_EVENT]); + expect(findings.some((f) => f.rule === FLOW_TRIGGER_UNROUTABLE)).toBe(false); + }); + + it('a genuinely manual flow — autolaunched/screen, the types this rule cannot reach', () => { + // The recorded counter-argument to the whole rule (#6637): the + // fall-through to "no binding" is ALSO how a flow legitimately is + // manual. Declaring `record_change` is what separates the two, so these + // must stay silent no matter how unroutable the token is. + for (const type of ['autolaunched', 'screen']) { + expect( + validateFlowTriggerReadiness(unroutable({ triggerType: 'onCreate' }, { type })).map((f) => f.rule), + type, + ).not.toContain(FLOW_TRIGGER_UNROUTABLE); + } + // Same fixture, one key changed back: `record_change` and it fires. + expect( + validateFlowTriggerReadiness(unroutable({ triggerType: 'onCreate' }, { type: 'record_change' })).map( + (f) => f.rule, + ), + ).toContain(FLOW_TRIGGER_UNROUTABLE); + }); + + it('a record_change flow that ALSO declares a trigger the engine DOES route', () => { + // These bind and fire — on the wrong trigger's terms. A real defect, a + // different one, and not this rule's to name (see 1f). Pinned so the + // criterion is not quietly widened into a second verdict. + for (const extra of [ + { schedule: { type: 'interval', intervalMs: 60000 } }, + { timeRelative: { object: 'app_candidate', dateField: 'due_at', withinDays: 7 } }, + ]) { + expect( + validateFlowTriggerReadiness(unroutable({ triggerType: 'onCreate', ...extra })).map((f) => f.rule), + JSON.stringify(extra), + ).not.toContain(FLOW_TRIGGER_UNROUTABLE); + } + // `triggerType: 'api'` routes on its own — the engine tests the token + // itself, so there is nothing to add to the fixture. + expect( + validateFlowTriggerReadiness(unroutable({ triggerType: 'api' })).map((f) => f.rule), + ).not.toContain(FLOW_TRIGGER_UNROUTABLE); + // Drop the routed sibling and the same flow is dead again. + expect( + validateFlowTriggerReadiness(unroutable({ triggerType: 'onCreate' })).map((f) => f.rule), + ).toEqual([FLOW_TRIGGER_UNROUTABLE]); + }); + + it('an ABSENT triggerType — dead too, deliberately deferred (#6637 corpus)', () => { + // Scope boundary, pinned rather than left to memory. A `record_change` + // flow with no triggerType at all resolves to no binding by the same + // fall-through and is just as dead — but it is an omission rather than a + // contradiction, and the corpus measurement found a LIVE instance of it + // in `examples/app-todo` (`TaskCompletionFlow`, #6882). Covering it here + // would gate a shipped example app on a guess about that app's + // semantics, so the criterion requires the key to be PRESENT and the + // omission case is filed separately. Widening this is then a deliberate + // edit that has to delete this test, not a side effect of touching the + // predicate. + expect( + validateFlowTriggerReadiness(unroutable({})).map((f) => f.rule), + ).not.toContain(FLOW_TRIGGER_UNROUTABLE); + // Non-vacuous: add the key back, and the same fixture fires. + expect( + validateFlowTriggerReadiness(unroutable({ triggerType: 'onCreate' })).map((f) => f.rule), + ).toEqual([FLOW_TRIGGER_UNROUTABLE]); + }); + + it('a flow with no start node at all', () => { + expect( + validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [{ name: 'no_start', type: 'record_change', status: 'active', nodes: [{ id: 'end', type: 'end' }] }], + }), + ).toEqual([]); + }); + }); + + it('co-fires with the timeRelative scalar rule — two keys, two facts', () => { + // `{ triggerType: 'onCreate', timeRelative: 'daily' }` is wrong twice over, + // at two different paths. The file's stated doctrine is two facts rather + // than one fact twice, so both are reported — and 1e's own consequence + // clause stays true, because nothing on this start node routes either. + const findings = validateFlowTriggerReadiness( + unroutable({ triggerType: 'onCreate', timeRelative: 'daily' }), + ); + expect(findings.map((f) => f.rule).sort()).toEqual( + [FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE].sort(), + ); + expect(new Set(findings.map((f) => f.path)).size).toBe(2); + expect( + findings.find((f) => f.rule === FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE)!.message, + ).toMatch(/binds to NOTHING/); + }); + + it('the id is the published slug and is distinct from the routed-token id', () => { + expect(FLOW_TRIGGER_UNROUTABLE).toBe('flow-trigger-unroutable'); + expect(FLOW_TRIGGER_UNROUTABLE).not.toBe(FLOW_TRIGGER_UNKNOWN_EVENT); + expect(FLOW_TRIGGER_UNROUTABLE).not.toBe(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE); + }); + }); + // ── #5762 — the family's severity map ──────────────────────────────────── // // The rules in this file were reviewed as ONE family and split on a single @@ -793,6 +986,27 @@ describe('validateFlowTriggerReadiness', () => { ], }, ], + [ + FLOW_TRIGGER_UNROUTABLE, + 'error', + { + objects: [candidateObject], + flows: [ + { + name: 'declared_dead', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + config: { objectName: 'app_candidate', triggerType: 'onCreate' }, + }, + ], + }, + ], + }, + ], // ── The controls. Both are hedged, and the hedge is the whole reason the // promotion above is not "everything in this file is an error now". [ diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 4334ba3a76..905252bef6 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -42,6 +42,13 @@ // ADR-0018, so the scalar parses fine), not `os validate`, and not even // the one bind-time warn rule 3's case gets. // +// 5. A `type: 'record_change'` flow whose start-node `triggerType` the engine +// routes NOWHERE — `triggerType: 'onCreate'` (#6637). The quietest member +// of the family: rules 3 and 4 are about one key's shape, this one is +// about a flow that declares WHAT it is and then contradicts it. See 1f +// for the measured silence — every named runtime channel skips it because +// they all key off the same resolution that already gave up. +// // The spec import is deliberate and is what makes rule 3 possible without a // second copy of the descriptor's shape living in this file. It stays inside the // package's stated dependency direction — lint → `@objectstack/spec`, never onto @@ -54,15 +61,24 @@ // It is whether THIS STACK is enough to know that: // // - `error` — the never-fire family. `flow-time-relative-descriptor-invalid`, -// `flow-time-relative-descriptor-unroutable` and -// `flow-trigger-unknown-event` each read a value whose verdict is settled by -// a contract that ships in this repo: `TimeRelativeTriggerSchema` for the +// `flow-time-relative-descriptor-unroutable`, `flow-trigger-unknown-event` +// and `flow-trigger-unroutable` each read a value whose verdict is settled +// by a contract that ships in this repo: `TimeRelativeTriggerSchema` for the // first, the engine's own `typeof … === 'object'` routing predicate for the -// second, `triggerTypeToHookEvents`' closed token grammar for the third. +// second, `triggerTypeToHookEvents`' closed token grammar for the third, and +// `resolveTriggerBinding`'s whole hardcoded branch chain for the fourth. // Nothing an author or a tenant can INSTALL changes any of those verdicts, // so there is no reading of the stack under which the flow fires. A rule // that can prove a declared trigger is dead should not be asking the author -// to notice a warning about it. +// to notice a warning about it. `flow-trigger-unroutable` is the one that +// had to be MEASURED rather than assumed (#6637): a plugin CAN supply a +// trigger implementation, so "installing something fixes it" is a live +// hypothesis for this id in a way it is not for the other three. It is +// false — `registerTrigger` is keyed by the RESOLVED type +// (`record_change` / `schedule` / `time_relative` / `api`), and the +// authored-token → resolved-type map is a private chain of literal +// `startsWith` / `typeof` tests with no registry lookup anywhere in it. No +// package can teach the engine a new authored token. // - `warning` — `flow-trigger-unknown-object`, both halves. An object name // this stack does not define may be defined by another installed package, // and this rule cannot see that package's objects. The hedge is real, so the @@ -131,6 +147,25 @@ export const FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID = 'flow-time-relative-descrip * there is no runtime channel at all for it to be moved earlier from. */ export const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = 'flow-time-relative-descriptor-unroutable'; +/** + * #6637 — a `type: 'record_change'` flow whose start-node `triggerType` the + * engine routes to NO trigger at all, so the flow is silently demoted to a + * manual one. + * + * A separate id from `flow-trigger-unknown-event`, on the same distinction that + * separates the two `timeRelative` ids: whether the engine ROUTES the value. + * The two partition the declared-but-dead tokens and can never both fire on one + * value, because being `record-`-prefixed is exactly what routes a token: + * + * - `record-`-prefixed but off-grammar (`record-after-updated`) — the engine + * ROUTES it to the record-change trigger, which maps it to zero hook events + * and says so in a bind-time warn. That is `…-UNKNOWN-EVENT`, and this rule + * file moves that warn earlier. + * - anything else (`onCreate`, `on_update`, `''`, `['onCreate']`) — the engine + * routes it NOWHERE. That is this id, and there is no runtime channel to + * move earlier from: see 1f for the three call sites that each skip it. + */ +export const FLOW_TRIGGER_UNROUTABLE = 'flow-trigger-unroutable'; type AnyRec = Record; @@ -175,6 +210,22 @@ function renderNonObject(v: unknown): string { return `a ${t}`; } +/** + * Render a `config.triggerType` for the 1f message. Unlike `renderNonObject` + * this one has to survive an ARRAY (`['onCreate']` is a real authored shape that + * reaches 1f — see 1d, which claims only the arrays holding a `record-` element), + * so it quotes strings and JSON-renders everything else, falling back to the + * bare type for the values `JSON.stringify` returns `undefined` for (a function, + * a symbol). Same reasoning as `renderNonObject`: a diagnostic that interpolates + * the word "undefined" into a sentence about a value that is very much present + * misreports its own subject. + */ +function renderTriggerToken(v: unknown): string { + if (typeof v === 'string') return `'${v}'`; + const json = JSON.stringify(v); + return json === undefined ? `a ${typeof v}` : json; +} + /** The start node of a flow definition, if any. */ function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined { const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; @@ -468,6 +519,102 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines }); } + // 1f. #6637 — a flow that DECLARES `type: 'record_change'` and then names a + // start-node `triggerType` the engine routes to no trigger at all. + // + // `triggerType: 'onCreate'` is the specimen. What actually happens was + // measured rather than assumed, because the card's word for it + // ("silently degrades") is a summary and a diagnostic has to be true: + // + // - `AutomationEngine.resolveTriggerBinding` tests the authored token + // with a literal `startsWith('record-')`, then tries the array form, + // `timeRelative`, `config.schedule`/`flow.type === 'schedule'`, and + // `flow.type === 'api'`/`triggerType === 'api'`. A token matching + // none of them falls off the end and the method returns `undefined`. + // - `activateFlowTrigger` opens with `if (!resolved) return;`, so the + // flow is never bound and never logs a word. It is now, in every + // respect the engine can observe, a manual flow. + // - `getTriggerBindingAudit` — the silent-miss audit built for exactly + // this class of defect — calls the SAME resolver and skips it with + // `if (!resolved) continue; // manual / screen flow — nothing to + // bind`. So the one channel that exists to name unbound flows cannot + // see this one, and neither can its two consumers: the automation + // plugin's `kernel:bootstrapped` warn loop, and the CLI startup + // summary's `unbound` list. + // + // What is left is not a diagnostic. `getFlowRuntimeStates` does report + // the flow with `bound: false` and `triggerType: undefined`, but the + // only place that surfaces is the banner's count line ("N flow(s) + // registered, N-1 bound") — a number with no flow name and no reason. + // So the message below says "nothing names it", which is the measured + // claim, rather than "no signal at all", which would be one count off. + // + // The scope is the narrow one, and the narrowing is what makes the rule + // safe: it speaks ONLY for flows that declare `type: 'record_change'`. + // Falling through to no binding is also the legitimate mechanism by + // which a flow IS manual, and `lint-flow-patterns.test.ts` already pins + // a deliberate `does NOT flag record_change` case for a neighbouring + // rule — so a criterion phrased on the fall-through ALONE would flag + // every manual and screen flow in existence. A flow that declares + // `record_change` has stated its own intent: `autolaunched` and `screen` + // are the types a genuinely manual flow declares, and neither reaches + // here. That is what makes this decidable at authoring time. + // + // Two shapes are deliberately NOT this rule's, each pinned by a test: + // + // - `triggerType` ABSENT on a `record_change` flow. Dead the same way + // and arguably worse, but it is an omission rather than a + // contradiction, and the corpus measurement (#6637) found a live + // instance of it in `examples/app-todo` whose repair is a judgement + // about that app's semantics, not a lint decision (#6882 — the flow + // also writes its predicate to a `triggerCondition` key nothing + // reads, so arming it is not a one-token edit). Widening this + // criterion to cover it would gate a shipped example app on a guess. + // The criterion here requires the key to be PRESENT so that widening + // is a deliberate act, not a side effect. + // - a `record_change` flow that ALSO declares something the engine + // does route (`config.schedule`, `triggerType: 'api'`). That flow + // binds and fires — on the wrong trigger's terms. A real defect, a + // different one ("mis-bound", not "never bound"), with its own + // severity argument to make. `routesToSomeTrigger` below is the + // engine's chain character for character precisely so this rule + // stays silent there instead of guessing at a second verdict. + const routesToSomeTrigger = + isRecordTriggered || + isArrayRecordTriggered || + isTimeRelative || + config.schedule != null || + flow.type === 'schedule' || + flow.type === 'api' || + triggerType === 'api'; + if (start && flow.type === 'record_change' && config.triggerType != null && !routesToSomeTrigger) { + findings.push({ + // `error` (#5762's criterion, applied to a fourth id). The verdict is + // the engine's own routing chain — literal `startsWith`/`typeof` tests + // with no registry lookup in them — so no installed package can make + // this token resolve. `registerTrigger` is keyed by the RESOLVED type, + // which is the near-miss worth stating: a plugin can supply the + // record-change trigger itself, and it still would not help, because + // the flow never reaches the point of asking for one. + severity: 'error', + rule: FLOW_TRIGGER_UNROUTABLE, + where: `flow "${flowName}" › start node`, + path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`, + message: + `declares type: 'record_change' but its start node's triggerType is ` + + `${renderTriggerToken(config.triggerType)}, which the engine routes to NO trigger — it binds a ` + + `record-change flow only for a token starting with 'record-', so this flow is demoted to a manual ` + + `one and never fires. Nothing NAMES it: the unbound-flow audit resolves the same binding and skips ` + + `the flow as "manual — nothing to bind", so neither the boot warning nor the startup summary lists ` + + `it; the only trace is the banner's flow count being one higher than its bound count.`, + hint: + `Use record-{before,after}-{create,update,delete,write} ('write' is create OR update in one flow, ` + + `#3427; create/insert are synonyms). If the flow really is launched by hand or from a screen, ` + + `declare type: 'autolaunched' or 'screen' instead of 'record_change' — those types have no trigger ` + + `to be missing.`, + }); + } + // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) {