diff --git a/.changeset/30016-flow-date-arithmetic-run-fails.md b/.changeset/30016-flow-date-arithmetic-run-fails.md new file mode 100644 index 00000000000..b8c28da5150 --- /dev/null +++ b/.changeset/30016-flow-date-arithmetic-run-fails.md @@ -0,0 +1,12 @@ +--- +'@objectstack/formula': patch +'@objectstack/lint': patch +--- + +fix(formula,lint): a date-arithmetic verdict in a flow condition says the run fails, not that the value reads null + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`@objectstack/formula`: four codes join `ExpressionRefusalCode`**: `date-arithmetic-run-fails`, `date-arithmetic-run-may-fail`, `date-text-arithmetic-run-fails` and `date-text-arithmetic-run-may-fail`, with the same params as `date-arithmetic` / `date-text-arithmetic`. A flow condition (a `'flattened'` site) that does arithmetic on a date field used to be told the field "silently evaluates to null", but the engine's `evaluateCondition` throws on the fault on every flow, so the run fails at the condition. Where the caller declares the run proven to hold the field (`faultOutcome: 'run-fails'`, with `bareNamesUnproven` unset) the verdict is served under `-run-fails` and says the run fails; anywhere else in a flow it is served under `-run-may-fail` and says the run fails wherever the name holds the field's date when the condition runs (a flow started by an API call, an action or a parent flow binds what its entrance hands over, which can be a number). The grade is unchanged: an error at every site. A `record`-scoped site (a formula field, a validation rule, a display predicate) keeps `date-arithmetic` / `date-text-arithmetic` and their sentence. A consumer keyed to the old codes on a flow condition re-keys to the new ones. +- **`@objectstack/lint`**: the fault-outcome ledger's docblock names the date verdicts' flow codes; the routing is the existing `faultOutcome` / `bareNamesUnproven` hint, unchanged. diff --git a/packages/formula/src/expression-refusal-codes.test.ts b/packages/formula/src/expression-refusal-codes.test.ts index 3ec9b582aee..66b037b91a3 100644 --- a/packages/formula/src/expression-refusal-codes.test.ts +++ b/packages/formula/src/expression-refusal-codes.test.ts @@ -295,14 +295,37 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin, ...Pin 'on dates: this faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span ' + 'in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', }, + ], + // [#30016] A flattened (flow) condition: the fault fails the run, it never + // reads null — `run-fails` where the run is proven to hold the field. + 'date-arithmetic-run-fails': [ + { + produce: () => + onlyError('predicate', 'close_date + 1 > 3', { + fields: ['close_date'], + fieldTypes: { close_date: 'date' }, + faultOutcome: 'run-fails', + }), + params: { operands: 'google.protobuf.Timestamp + int', reference: 'close_date' }, + message: + 'date arithmetic `google.protobuf.Timestamp + int` — `close_date` is a date, and CEL can\'t do arithmetic ' + + 'on dates: this faults at runtime, and ' + + 'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' + + 'exists to do is not done (it does not read null). ' + + 'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', + }, + ], + 'date-arithmetic-run-may-fail': [ { produce: () => onlyError('predicate', 'close_date + 1 > 3', { fields: ['close_date'], fieldTypes: { close_date: 'date' } }), params: { operands: 'google.protobuf.Timestamp + int', reference: 'close_date' }, message: 'date arithmetic `google.protobuf.Timestamp + int` — `close_date` is a date, and CEL can\'t do arithmetic ' + - 'on dates: this faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span ' + - 'in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', + 'on dates: this faults wherever `close_date` holds the date when the condition runs, and ' + + 'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' + + 'exists to do is not done (it does not read null). ' + + 'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', }, ], 'date-text-arithmetic': [ @@ -319,18 +342,40 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin, ...Pin 'faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span in whole days, ' + 'and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', }, + ], + 'date-text-arithmetic-run-fails': [ { produce: () => onlyError('predicate', 'end_at - start_at > duration("1h")', { fields: ['start_at', 'end_at'], fieldTypes: { start_at: 'datetime', end_at: 'datetime' }, + faultOutcome: 'run-fails', }), params: { operands: 'DateText - DateText', reference: 'start_at' }, message: 'date arithmetic `DateText - DateText` — `start_at` is a date field, and the runtime holds a date field ' + - 'as its text (`YYYY-MM-DD`, or an ISO-8601 instant for a date-time), which CEL can\'t do arithmetic on: this ' + - 'faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span in whole days, ' + - 'and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', + 'as its text (`YYYY-MM-DD`, or an ISO-8601 instant for a date-time), which CEL can\'t do arithmetic on: ' + + 'this faults at runtime, and ' + + 'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' + + 'exists to do is not done (it does not read null). ' + + 'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', + }, + ], + 'date-text-arithmetic-run-may-fail': [ + { + produce: () => + onlyError('predicate', 'end_at - start_at > duration("1h")', { + fields: ['start_at', 'end_at'], + fieldTypes: { start_at: 'datetime', end_at: 'datetime' }, + }), + params: { operands: 'DateText - DateText', reference: 'start_at' }, + message: + 'date arithmetic `DateText - DateText` — `start_at` is a date field, and the runtime holds a date field ' + + 'as its text (`YYYY-MM-DD`, or an ISO-8601 instant for a date-time), which CEL can\'t do arithmetic on: ' + + 'this faults wherever `start_at` holds that text when the condition runs, and ' + + 'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' + + 'exists to do is not done (it does not read null). ' + + 'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', }, ], 'date-duration-value': [ @@ -594,6 +639,7 @@ const SWEEP_SOURCES: readonly string[] = [ 'close_date + 1 > 3', 'record.close_date - record.close_date', 'today() - record.close_date', + 'close_date - close_date > duration("1h")', 'record.name > 5', 'record.active + 1 > 5', "record.account == 'a1' && record.account.name == 'x'", @@ -631,6 +677,10 @@ const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [ optionFields: ['status'], }, { fields: ['status', 'close_date'], objectName: 'deal', fieldTypes: { close_date: 'date' } }, + // [#30016] The same flow condition where the run is proven to hold the + // field: the date verdicts' `-run-fails` codes (the line above reaches + // `-run-may-fail`). + { fields: ['status', 'close_date'], objectName: 'deal', fieldTypes: { close_date: 'date' }, faultOutcome: 'run-fails' }, // [#29926] The deal schema once per declared fault outcome, so the sweep // reaches each `type-mismatch-*` code and holds its params to its message. ...(['fail-closed', 'run-fails', 'fail-open'] as const).map((faultOutcome): ExprSchemaHint => ({ diff --git a/packages/formula/src/expression-refusal.ts b/packages/formula/src/expression-refusal.ts index 0716b7daf45..0c1547ef375 100644 --- a/packages/formula/src/expression-refusal.ts +++ b/packages/formula/src/expression-refusal.ts @@ -164,16 +164,35 @@ export interface ExpressionRefusalParams { 'bare-reference': { readonly name: string }; /** * Arithmetic on a date field against a number. `operands` are the CEL - * operand types; `reference` is the field as written, when it is known. + * operand types; `reference` is the field as written, when it is known. A + * flattened (flow) condition is `date-arithmetic-run-fails` / + * `date-arithmetic-run-may-fail`. */ 'date-arithmetic': { readonly operands: string; readonly reference?: string }; /** * Arithmetic on a date field's runtime TEXT that the evaluator cannot answer * (`record.end_date - record.start_date`), judged with the field declared as * that text. `operands` are the CEL operand types (the text is `DateText`); - * `reference` is the field as written, when it is known. + * `reference` is the field as written, when it is known. A flattened (flow) + * condition is `date-text-arithmetic-run-fails` / `-run-may-fail`. */ 'date-text-arithmetic': { readonly operands: string; readonly reference?: string }; + /** + * [#30016] ERROR: `date-arithmetic` in a flow condition whose run is proven + * to hold the field there (`faultOutcome: 'run-fails'`) — the condition + * faults, so the run fails at it. It never reads null. + */ + 'date-arithmetic-run-fails': { readonly operands: string; readonly reference?: string }; + /** + * [#30016] ERROR: `date-arithmetic` in any other flow condition — it faults, + * and so fails the run, wherever the name holds the field's date when the + * condition runs; what the run binds under the name is not proven there. + */ + 'date-arithmetic-run-may-fail': { readonly operands: string; readonly reference?: string }; + /** [#30016] ERROR: `date-text-arithmetic`, at a `date-arithmetic-run-fails` site. */ + 'date-text-arithmetic-run-fails': { readonly operands: string; readonly reference?: string }; + /** [#30016] ERROR: `date-text-arithmetic`, at a `date-arithmetic-run-may-fail` site. */ + 'date-text-arithmetic-run-may-fail': { readonly operands: string; readonly reference?: string }; /** * A formula that computes a `google.protobuf.Duration` * (`today() - record.start_date`), which a field reads as `{}`. `reference` @@ -274,6 +293,10 @@ const CODE_TABLE = { 'bare-reference': true, 'date-arithmetic': true, 'date-text-arithmetic': true, + 'date-arithmetic-run-fails': true, + 'date-arithmetic-run-may-fail': true, + 'date-text-arithmetic-run-fails': true, + 'date-text-arithmetic-run-may-fail': true, 'date-duration-value': true, 'type-mismatch': true, 'type-mismatch-fail-closed': true, diff --git a/packages/formula/src/validate-date-text-arithmetic.test.ts b/packages/formula/src/validate-date-text-arithmetic.test.ts index 3603db7787c..4ce6badc2ca 100644 --- a/packages/formula/src/validate-date-text-arithmetic.test.ts +++ b/packages/formula/src/validate-date-text-arithmetic.test.ts @@ -119,14 +119,25 @@ describe('date subtraction the runtime cannot answer (#28944)', () => { expect(since.message).toMatch(/daysBetween\(a, b\)/); }); - it('judges a flattened (flow) condition the same way', () => { + it('judges a flattened (flow) condition the same way, under the flow codes (#30016)', () => { const flattened: ExprSchemaHint = { fields: ['start_date', 'end_date'], fieldTypes: { start_date: 'date', end_date: 'date' }, }; + // No declared outcome: the run fails wherever the name holds the date's text. const refused = validateExpression('predicate', 'end_date - start_date > duration("72h")', flattened); - expect(refused.errors.map((e) => e.code)).toEqual(['date-text-arithmetic']); + expect(refused.errors.map((e) => e.code)).toEqual(['date-text-arithmetic-run-may-fail']); expect(refused.errors[0].params).toEqual({ operands: 'DateText - DateText', reference: 'start_date' }); + expect(refused.errors[0].message).not.toMatch(/evaluates to null/); + // The run is proven to hold the field: it fails at the condition. + const proven = validateExpression('predicate', 'end_date - start_date > duration("72h")', { ...flattened, faultOutcome: 'run-fails' }); + expect(proven.errors.map((e) => e.code)).toEqual(['date-text-arithmetic-run-fails']); + expect(proven.errors[0].message).toMatch(/this faults at runtime, and a flow condition that cannot be evaluated fails the run/); + // A node can rebind the bare names: not proven, whatever the outcome. + const unproven = validateExpression('predicate', 'end_date - start_date > duration("72h")', { + ...flattened, faultOutcome: 'run-fails', bareNamesUnproven: true, + }); + expect(unproven.errors.map((e) => e.code)).toEqual(['date-text-arithmetic-run-may-fail']); expect(validateExpression('predicate', 'today() - start_date > duration("72h")', flattened).errors).toEqual([]); }); diff --git a/packages/formula/src/validate.test.ts b/packages/formula/src/validate.test.ts index 4f662fec708..add3d12eca8 100644 --- a/packages/formula/src/validate.test.ts +++ b/packages/formula/src/validate.test.ts @@ -1063,6 +1063,34 @@ describe('validateExpression (ADR-0032)', () => { expect(r.ok).toBe(true); expect(r.errors).toHaveLength(0); }); + + // [#30016] A flow condition's fault fails the run (`evaluateCondition` + // throws on every flow), so the flow codes never say it reads null. The + // grade stays an error at every site. + it('in a flow condition says the run fails, and keeps the null sentence on a record-scoped rule (#30016)', () => { + const flow = { ...schema, scope: 'flattened' } as const; + const codeOf = (hint: object) => { + const r = validateExpression('predicate', 'start_date + 1 > 3', { ...flow, ...hint }); + expect(r.warnings).toHaveLength(0); + return r.errors.map((e) => [e.code, e.message] as const); + }; + const [[provenCode, proven]] = codeOf({ faultOutcome: 'run-fails' }); + expect(provenCode).toBe('date-arithmetic-run-fails'); + expect(proven).toMatch(/this faults at runtime, and a flow condition that cannot be evaluated fails the run/); + expect(proven).not.toMatch(/evaluates to null/); + for (const hint of [{}, { faultOutcome: 'run-fails', bareNamesUnproven: true }]) { + const [[code, message]] = codeOf(hint); + expect(code).toBe('date-arithmetic-run-may-fail'); + expect(message).toMatch(/this faults wherever `start_date` holds the date when the condition runs, and a flow condition/); + expect(message).not.toMatch(/evaluates to null/); + } + // A bare name a flow variable holds is not the field: no verdict. + expect(codeOf({ faultOutcome: 'run-fails', flowVariables: ['start_date'] })).toEqual([]); + // Control: a record-scoped validation rule keeps its code and sentence. + const rule = validateExpression('predicate', 'record.start_date + 1 > 3', { ...schema, faultOutcome: 'fail-closed' }); + expect(rule.errors.map((e) => e.code)).toEqual(['date-arithmetic']); + expect(rule.errors[0].message).toMatch(/this faults at runtime, so the field silently evaluates to null/); + }); }); describe('introspection', () => { diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index d8b2c3b8571..9c19c3c3ddd 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -401,8 +401,11 @@ const TYPE_MISMATCH_FAULT_ARMS: { * #1928 / #3306 — a type-soundness verdict for an expression, or `null` when * sound. Two categories, with different severities: * - `date-arith` (**error**): arithmetic on a date field against a number - * (`date − date + 1`, `today() + 30`) — always nulls at runtime and never - * recovers, so it blocks the build. + * (`date − date + 1`, `today() + 30`) — always faults at runtime and never + * recovers, so it blocks the build. A formula reads that fault as null; a + * flattened (flow) condition's fault fails the run, under the + * `date-arithmetic-run-fails` / `-run-may-fail` codes (#30016, + * {@link dateFlowArm}). * - `type-mismatch` (**warning**): a text/boolean field used with an * arithmetic/ordering operator against a number — nulls unless the text value * happens to be numeric, so it stays advisory. An **error** where the caller @@ -416,7 +419,8 @@ const TYPE_MISMATCH_FAULT_ARMS: { * bare name is. * - `date-text-arithmetic` / `date-duration-value` (**error**, #28944): what * the Timestamp typing passes, re-checked with a date field declared as the - * text the runtime holds — see {@link dateTextIssue}. + * text the runtime holds — see {@link dateTextIssue}; in a flow condition + * the fault takes the same two flow codes as `date-arith`. * `scope` selects `record.` vs bare field binding, shaping the message. */ function typeSoundnessIssue( @@ -453,7 +457,7 @@ function typeSoundnessIssue( const bareCelTypes = shadowed ? Object.fromEntries(Object.entries(celFieldTypes).filter(([name]) => !shadowed.has(name))) : celFieldTypes; - return dateTextIssue(source, bareCelTypes, scope, role); + return dateTextIssue(source, bareCelTypes, scope, role, dateFlowArm(scope, faultOutcome, bareNamesUnproven)); } const reference = mismatch.field ? (mismatch.root !== undefined @@ -463,17 +467,22 @@ function typeSoundnessIssue( const ref = reference !== undefined ? `\`${reference}\`` : null; if (mismatch.category === 'date-arith') { const subject = ref ? `${ref} is a date` : 'a date field'; + const params = { operands: mismatch.operands, ...(reference !== undefined ? { reference } : {}) }; + const head = `date arithmetic \`${mismatch.operands}\` — ${subject}, and CEL can't do arithmetic on dates: `; + const use = + `Use \`daysBetween(a, b)\` for the span in whole days, and \`daysFromNow(n)\` / ` + + `\`addDays(d, n)\` / \`addMonths(d, n)\` to shift a date.`; + const arm = dateFlowArm(scope, faultOutcome, bareNamesUnproven); return { severity: 'error', - issue: refusal( - 'date-arithmetic', - { operands: mismatch.operands, ...(reference !== undefined ? { reference } : {}) }, - source, - `date arithmetic \`${mismatch.operands}\` — ${subject}, and CEL can't do arithmetic on ` + - `dates: this faults at runtime, so the field silently evaluates to null. Use ` + - `\`daysBetween(a, b)\` for the span in whole days, and \`daysFromNow(n)\` / ` + - `\`addDays(d, n)\` / \`addMonths(d, n)\` to shift a date.`, - ), + issue: arm + ? refusal(`date-arithmetic-${arm}`, params, source, `${head}${dateFlowFault(arm, ref, 'date')} ${use}`) + : refusal( + 'date-arithmetic', + params, + source, + `${head}this faults at runtime, so the field silently evaluates to null. ${use}`, + ), }; } const heldKind: 'boolean' | 'text' = mismatch.celType === 'bool' ? 'boolean' : 'text'; @@ -548,13 +557,45 @@ function typeSoundnessIssue( }; } +/** + * [#30016] Which flow sentence a date verdict takes, or `null` off a flow. A + * flattened site is a flow condition, and the engine's `evaluateCondition` + * throws on a fault (ADR-0032 §1c) on every flow: a date verdict there never + * reads null. `run-fails` where the caller declares the run proven to hold the + * field (`faultOutcome: 'run-fails'`) and no node can rebind a bare name + * (`bareNamesUnproven`) — the date verdicts read bare names only. Anywhere + * else in a flow the name holds what the entrance handed the run (an API body + * whose value is a number answers `close_date + 1`), so the run fails only + * where it holds the date: `run-may-fail`. The grade stays an error either way. + */ +type DateFlowArm = 'run-fails' | 'run-may-fail'; +function dateFlowArm( + scope: 'record' | 'flattened', + faultOutcome: ExprSchemaHint['faultOutcome'], + bareNamesUnproven: boolean | undefined, +): DateFlowArm | null { + if (scope !== 'flattened') return null; + return faultOutcome === 'run-fails' && !bareNamesUnproven ? 'run-fails' : 'run-may-fail'; +} + +/** [#30016] The fault clause of a flow date verdict; `held` names what the field holds at run time. */ +function dateFlowFault(arm: DateFlowArm, ref: string | null, held: 'date' | 'text'): string { + const fails = + 'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the ' + + 'work the flow exists to do is not done (it does not read null).'; + if (arm === 'run-fails') return `this faults at runtime, and ${fails}`; + return `this faults wherever ${ref ?? 'the field'} holds ${held === 'date' ? 'the date' : 'that text'} ` + + `when the condition runs, and ${fails}`; +} + /** * #28944 — the date shapes the Timestamp typing of {@link firstTypeMismatch} * passes and the runtime cannot answer, judged by {@link firstDateTextVerdict} * with every `date`/`datetime` field declared as the text the runtime holds: * - `date-text-arithmetic`: arithmetic on that text the evaluator faults on — * `record.end_date - record.start_date` (either field kind), `record.d - - * duration('24h')` — so the expression reads null. Any role. + * duration('24h')` — so the expression reads null (in a flow condition the + * run fails instead: `flowArm`, #30016). Any role. * - `date-duration-value`: a formula (`value` role) that computes a * `google.protobuf.Duration` — `today() - record.start_date` — which a field * serialises as `{}`. A predicate that COMPARES a duration @@ -567,6 +608,7 @@ function dateTextIssue( celFieldTypes: Readonly>, scope: 'record' | 'flattened', role: 'predicate' | 'value', + flowArm: DateFlowArm | null, ): { issue: ExprValidationError; severity: 'error' | 'warning' } | null { if (role !== 'value' && !Object.values(celFieldTypes).includes('timestamp')) return null; const verdict = firstDateTextVerdict(source, celFieldTypes, scope); @@ -578,17 +620,22 @@ function dateTextIssue( const shift = `\`daysFromNow(n)\` / \`addDays(d, n)\` / \`addMonths(d, n)\` to shift a date.`; if (verdict.kind === 'fault') { const subject = ref ? `${ref} is a date field` : 'a date field is an operand'; + const params = { operands: verdict.operands, ...(reference !== undefined ? { reference } : {}) }; + const head = + `date arithmetic \`${verdict.operands}\` — ${subject}, and the runtime holds a date field as ` + + `its text (\`YYYY-MM-DD\`, or an ISO-8601 instant for a date-time), which CEL can't do ` + + `arithmetic on: `; + const use = `Use \`daysBetween(a, b)\` for the span in whole days, and ${shift}`; return { severity: 'error', - issue: refusal( - 'date-text-arithmetic', - { operands: verdict.operands, ...(reference !== undefined ? { reference } : {}) }, - source, - `date arithmetic \`${verdict.operands}\` — ${subject}, and the runtime holds a date field as ` + - `its text (\`YYYY-MM-DD\`, or an ISO-8601 instant for a date-time), which CEL can't do ` + - `arithmetic on: this faults at runtime, so the field silently evaluates to null. Use ` + - `\`daysBetween(a, b)\` for the span in whole days, and ${shift}`, - ), + issue: flowArm + ? refusal(`date-text-arithmetic-${flowArm}`, params, source, `${head}${dateFlowFault(flowArm, ref, 'text')} ${use}`) + : refusal( + 'date-text-arithmetic', + params, + source, + `${head}this faults at runtime, so the field silently evaluates to null. ${use}`, + ), }; } if (role !== 'value' || verdict.type !== 'google.protobuf.Duration') return null; diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index c471e4892e0..01e4f3384e3 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -1149,6 +1149,65 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { } }); + // [#30016] The date verdicts were left saying a flow condition "silently + // evaluates to null"; `evaluateCondition` throws on a fault on every flow. + // Where the ledger proves the run holds the field (`run-fails`) the fault + // fails the run; on any other flow it does wherever the name holds the + // date (an API body may hand a number). An error at every site, as before. + it('says a date-arithmetic fault in a flow condition fails the run, at start, edge and decision (#30016)', () => { + const objects = [{ name: 'crm_opportunity', fields: { close_date: { type: 'date' }, open_date: { type: 'date' } } }]; + const dateVerdicts = (issues: ReturnType) => + issues.filter(i => /^date arithmetic/.test(i.message)); + for (const [name, startConfig, flowType, proven] of [ + ['rt_flow', { triggerType: 'record-after-update' }, undefined, true], + ['sched_flow', { schedule: '0 * * * *' }, undefined, false], + ['api_flow', {}, 'api', false], + ] as const) { + const issues = validateStackExpressions({ + objects, + flows: [{ + name, + ...(flowType ? { type: flowType } : {}), + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'crm_opportunity', condition: 'close_date + 1 > 3', ...startConfig } }, + { id: 'route', type: 'decision', config: { conditions: [{ label: 'Late', expression: 'close_date - open_date > duration("1h")' }] } }, + { id: 'end', type: 'end' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'route', condition: 'close_date + 7 > 3' }, + { id: 'e2', source: 'route', target: 'end', label: 'Late' }, + ], + }], + }); + const found = dateVerdicts(issues); + expect(found.map(i => [i.where, i.severity]), name).toEqual([ + [`flow '${name}' · node 'start' (start) condition`, 'error'], + [`flow '${name}' · node 'route' (decision) decision branch expression at config.conditions[0].expression`, 'error'], + [`flow '${name}' · edge 'e1' (start→route) condition`, 'error'], + ]); + for (const i of found) { + expect(i.message, `${name} ${i.where}`).toContain('a flow condition that cannot be evaluated fails the run'); + expect(i.message, `${name} ${i.where}`).toMatch( + proven ? /: this faults at runtime, and a flow condition/ : /: this faults wherever `close_date` holds (the date|that text) when the condition runs/, + ); + expect(i.message, `${name} ${i.where}`).not.toMatch(/evaluates to null/); + } + } + }); + + it('keeps the null sentence on a record-scoped validation rule\'s date arithmetic (#30016 control)', () => { + const issues = validateStackExpressions({ + objects: [{ + name: 'crm_opportunity', + fields: { close_date: { type: 'date' } }, + validations: [{ name: 'late', condition: 'record.close_date + 1 > 3' }], + }], + }); + const found = issues.filter(i => /^date arithmetic/.test(i.message)); + expect(found.map(i => i.severity)).toEqual(['error']); + expect(found[0]!.message).toContain('this faults at runtime, so the field silently evaluates to null'); + }); + // [#29959] `seedRunVariables` seeds the flow's declared variables before it // flattens the record, and the flattening skips a name already bound: a // bare `title` here reads the NUMBER variable and `title > 5` runs. Only diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index ae84329646e..58b2b725630 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1329,6 +1329,9 @@ function isFlattenedNearMissFor(diagnostic: string, roots: readonly string[]): b * declares none, it stays the advisory warning. An advisory rule is graded * `error` too: the twin refuses it, and a rule that can never be evaluated * never gives its advisory — the message says so instead of a refused write. + * The date verdicts (`date-arithmetic`, `date-text-arithmetic`) are an error + * at every site; in a flow condition the same outcome picks their sentence: + * `-run-fails` under `run-fails`, `-run-may-fail` anywhere else (#30016). */ type FaultSite = | { readonly kind: 'validation rule'; readonly severity: unknown }