@@ -1073,36 +1073,197 @@ function isNumericOverloadError(err: unknown): boolean {
10731073}
10741074
10751075/**
1076- * Recursively coerce string values that faulted a CEL overload into their
1077- * intended primitive: entirely-numeric literals → `number` (#1534), and
1078- * ISO-8601 date / date-time strings → `Date` (cel-js `google.protobuf.Timestamp`)
1079- * (#1530). Used only on the {@link isNumericOverloadError} retry path, so it can
1080- * never change a comparison that already evaluated cleanly — it only rescues one
1081- * that already faulted. Strings that are neither (a zip like `"02134"`, free
1082- * text) pass through untouched; if the retry still cannot type-check, the
1083- * original loud error is preserved.
1076+ * The operators that RAISE on a string-versus-number/Timestamp operand pair
1077+ * instead of answering one. This membership is the entire basis of the §1c
1078+ * rescue below — for these operators a mixed pair cannot have produced an
1079+ * answer, so rewriting the operand cannot change one.
1080+ *
1081+ * Measured per operator on cel-js 8.0.0 (#7098), against an int literal, a
1082+ * number-valued field, a `today()` Timestamp and a Date-valued field:
1083+ *
1084+ * - `<` `<=` `>` `>=` `+` `-` `*` `/` `%` — **fault**, every shape:
1085+ * `no such overload: dyn<string> >= int`. Listed.
1086+ * - `==` `!=` — **answer**, `false` / `true`. CEL equality is defined across
1087+ * types, so `record.s == 5` over `{ s: "5" }` is a clean `false`, not a
1088+ * fault. DELIBERATELY ABSENT: coercing an equality is exactly the defect
1089+ * this function closes — the author's string equality already had an answer.
1090+ * The separate problem that a `Field.date` string never equals a Timestamp is
1091+ * owned by {@link rewriteTemporalEquality}, which wraps it statically and
1092+ * per-occurrence on the CLEAN path, where the two sides are known from the
1093+ * source rather than guessed from an unrelated conjunct's fault.
1094+ * - `in` — **answers** too (`"7" in [1, 7]` is a clean `false`). Absent for the
1095+ * same reason.
10841096 */
1085- function hydrateOverloadStrings ( value : unknown ) : unknown {
1086- if ( typeof value === 'string' ) {
1087- const trimmed = value . trim ( ) ;
1088- if ( trimmed . length > 0 ) {
1089- if ( NUMERIC_STRING_RE . test ( trimmed ) ) {
1090- const n = Number ( trimmed ) ;
1091- if ( Number . isFinite ( n ) ) return n ;
1092- } else if ( ISO_TEMPORAL_STRING_RE . test ( trimmed ) ) {
1093- const ms = Date . parse ( trimmed ) ;
1094- if ( ! Number . isNaN ( ms ) ) return new Date ( ms ) ;
1095- }
1096- }
1097- return value ;
1097+ const COERCIBLE_OPS : ReadonlySet < string > = new Set ( [
1098+ '<' , '<=' , '>' , '>=' , '+' , '-' , '*' , '/' , '%' ,
1099+ ] ) ;
1100+
1101+ /** What an operand will actually BE at evaluation time — see {@link operandKind}. */
1102+ type OperandKind = 'number' | 'temporal' | 'string' | 'unknown' ;
1103+
1104+ /**
1105+ * The scope path a node names, or null when it names none: `record.n` →
1106+ * `['record','n']`, a bare `status` (the flattened flow scope) → `['status']`,
1107+ * and `record.items[0].price` / `record["n"]` → the same walk through a CONSTANT
1108+ * index. Null for everything else — a call, an arithmetic sub-tree, a variable
1109+ * bound by a comprehension — which is what keeps the rewrite below to operands
1110+ * whose runtime value we can actually read before deciding.
1111+ */
1112+ function scopePath ( node : unknown ) : string [ ] | null {
1113+ if ( ! isCelNode ( node ) ) return null ;
1114+ if ( node . op === 'id' && typeof node . args === 'string' ) return [ node . args ] ;
1115+ if ( node . op === '.' && Array . isArray ( node . args ) && node . args . length === 2 ) {
1116+ const [ base , member ] = node . args ;
1117+ if ( typeof member !== 'string' ) return null ;
1118+ const head = scopePath ( base ) ;
1119+ return head ? [ ...head , member ] : null ;
10981120 }
1099- if ( Array . isArray ( value ) ) return value . map ( hydrateOverloadStrings ) ;
1100- if ( value && typeof value === 'object' && ! ( value instanceof Date ) ) {
1101- const out : Record < string , unknown > = { } ;
1102- for ( const [ k , v ] of Object . entries ( value ) ) out [ k ] = hydrateOverloadStrings ( v ) ;
1103- return out ;
1121+ if ( node . op === '[]' && Array . isArray ( node . args ) && node . args . length === 2 ) {
1122+ const [ base , index ] = node . args ;
1123+ if ( ! isCelNode ( index ) || index . op !== 'value' ) return null ;
1124+ const key = index . args ;
1125+ if ( typeof key !== 'string' && typeof key !== 'bigint' && typeof key !== 'number' ) return null ;
1126+ const head = scopePath ( base ) ;
1127+ return head ? [ ...head , String ( key ) ] : null ;
11041128 }
1105- return value ;
1129+ return null ;
1130+ }
1131+
1132+ /** Resolve a {@link scopePath} against the scope; `undefined` when any hop is absent. */
1133+ function resolveScopePath ( scope : Record < string , unknown > , path : readonly string [ ] ) : unknown {
1134+ let cur : unknown = scope ;
1135+ for ( const seg of path ) {
1136+ if ( cur == null || typeof cur !== 'object' ) return undefined ;
1137+ cur = ( cur as Record < string , unknown > ) [ seg ] ;
1138+ }
1139+ return cur ;
1140+ }
1141+
1142+ /** The {@link OperandKind} of a concrete runtime value. */
1143+ function valueKind ( v : unknown ) : OperandKind {
1144+ if ( typeof v === 'number' || typeof v === 'bigint' ) return 'number' ;
1145+ if ( v instanceof Date ) return 'temporal' ;
1146+ if ( typeof v === 'string' ) return 'string' ;
1147+ return 'unknown' ;
1148+ }
1149+
1150+ /**
1151+ * What the operand will actually be when cel-js evaluates it — read off the
1152+ * literal, off the known return type of a stdlib call, or (for a scope path) off
1153+ * the value ALREADY IN HAND in this scope. Reading the scope rather than the
1154+ * static type is what makes the "this comparison provably faulted" test exact
1155+ * under `unlistedVariablesAreDyn`, where every field is statically `dyn`.
1156+ *
1157+ * `unknown` is the safe answer and the common one: an arithmetic sub-tree, a
1158+ * comprehension variable, an absent key. An `unknown` counterpart never licenses
1159+ * a rewrite.
1160+ */
1161+ function operandKind ( node : unknown , scope : Record < string , unknown > ) : OperandKind {
1162+ if ( ! isCelNode ( node ) ) return 'unknown' ;
1163+ if ( node . op === 'value' ) return valueKind ( node . args ) ;
1164+ if ( isTemporalCall ( node ) ) return 'temporal' ;
1165+ if ( node . op === 'call' && Array . isArray ( node . args ) && typeof node . args [ 0 ] === 'string' ) {
1166+ const fn = node . args [ 0 ] ;
1167+ if ( fn === 'date' || fn === 'datetime' ) return 'temporal' ;
1168+ if ( fn === 'double' || fn === 'int' || fn === 'uint' ) return 'number' ;
1169+ return 'unknown' ;
1170+ }
1171+ const path = scopePath ( node ) ;
1172+ if ( ! path ) return 'unknown' ;
1173+ const resolved = resolveScopePath ( scope , path ) ;
1174+ return resolved === undefined ? 'unknown' : valueKind ( resolved ) ;
1175+ }
1176+
1177+ /**
1178+ * The coercion this operand needs to meet `counterpart`, or null when it is not
1179+ * one ADR-0032 §1c rescues: entirely-numeric literals → `double(…)` (#1534) and
1180+ * ISO-8601 date / date-time strings → `date(…)` (#1530). Strings that are
1181+ * neither — a zip like `"02134"`, free text — return null and the original loud
1182+ * fault is preserved, exactly as before.
1183+ *
1184+ * The coercion must MATCH the counterpart: a numeric string opposite a Timestamp
1185+ * (or an ISO string opposite a number) is a genuine mismatch, not a §1c
1186+ * serialization artifact, and is left to fault.
1187+ */
1188+ function coercionFor ( value : unknown , counterpart : OperandKind ) : 'double' | 'date' | null {
1189+ if ( typeof value !== 'string' ) return null ;
1190+ const trimmed = value . trim ( ) ;
1191+ if ( trimmed . length === 0 ) return null ;
1192+ if ( counterpart === 'number' && NUMERIC_STRING_RE . test ( trimmed ) ) {
1193+ return Number . isFinite ( Number ( trimmed ) ) ? 'double' : null ;
1194+ }
1195+ if ( counterpart === 'temporal' && ISO_TEMPORAL_STRING_RE . test ( trimmed ) ) {
1196+ return Number . isNaN ( Date . parse ( trimmed ) ) ? null : 'date' ;
1197+ }
1198+ return null ;
1199+ }
1200+
1201+ /** Wrap an AST node in a one-argument stdlib call (`double(x)` / `date(x)`). */
1202+ function wrapInCall ( fn : string , node : CelNode ) : CelNode {
1203+ return { op : 'call' , args : [ fn , [ node ] ] } ;
1204+ }
1205+
1206+ /**
1207+ * #7098 — coerce the operands that PROVABLY faulted, and only those.
1208+ *
1209+ * The predecessor of this function hydrated the whole scope and re-ran the
1210+ * expression, on a docblock claim that "it can never change a comparison that
1211+ * already evaluated cleanly". That claim was false and load-bearing: the retry
1212+ * knows only that the WHOLE expression faulted, so rewriting the scope
1213+ * re-interprets every OTHER comparison too. `record.n >= 4 && record.s == "5.0"`
1214+ * over `{ n: "7", s: "5.0" }` faults on the first conjunct, hydrates BOTH fields,
1215+ * and answers `false` — the author's deliberate string equality was `true` in
1216+ * evaluation 1, and nothing reports that it was overruled.
1217+ *
1218+ * So the rewrite is now **per-occurrence**, the same discipline
1219+ * {@link rewriteTemporalEquality} already documents ("no field-wide trade-off"),
1220+ * and one step stricter — it is per operand POSITION, so a field compared to an
1221+ * int in one conjunct and to a string literal in another keeps both answers.
1222+ *
1223+ * An operand is rewritten only where all three hold, which together make the
1224+ * docblock's guarantee true by construction rather than by assertion:
1225+ * 1. the operator is one of {@link COERCIBLE_OPS} — no string↔number/Timestamp
1226+ * overload exists, so a mixed pair cannot have produced an answer;
1227+ * 2. the counterpart is a number or a Timestamp *in this scope*, established by
1228+ * {@link operandKind} against the values in hand, not by static type;
1229+ * 3. this operand's own value is a §1c serialization artifact
1230+ * ({@link coercionFor}).
1231+ *
1232+ * Returns the rewritten source, or null when no operand qualifies — in which
1233+ * case the caller preserves the original loud error rather than guessing. That
1234+ * is the deliberate trade: shapes the walk cannot read (a comprehension
1235+ * variable, a computed index) now FAULT where they were once silently rescued,
1236+ * because a silent rescue of an operand we cannot prove faulted is precisely the
1237+ * defect this closes.
1238+ */
1239+ function rewriteFaultedOperands ( source : string , scope : Record < string , unknown > ) : string | null {
1240+ let ast : unknown ;
1241+ try {
1242+ ast = ( recordScopeEnv ??= buildScopedEnv ( [ ] ) ) . parse ( source ) . ast ;
1243+ } catch {
1244+ return null ;
1245+ }
1246+ let changed = false ;
1247+ const visit = ( node : unknown ) : void => {
1248+ if ( ! isCelNode ( node ) ) return ;
1249+ if ( COERCIBLE_OPS . has ( node . op ) && Array . isArray ( node . args ) && node . args . length === 2 ) {
1250+ const args = node . args as unknown [ ] ;
1251+ for ( const side of [ 0 , 1 ] as const ) {
1252+ const operand = args [ side ] ;
1253+ const path = scopePath ( operand ) ;
1254+ if ( ! path ) continue ;
1255+ const counterpart = operandKind ( args [ 1 - side ] , scope ) ;
1256+ if ( counterpart !== 'number' && counterpart !== 'temporal' ) continue ;
1257+ const fn = coercionFor ( resolveScopePath ( scope , path ) , counterpart ) ;
1258+ if ( ! fn ) continue ;
1259+ args [ side ] = wrapInCall ( fn , operand as CelNode ) ;
1260+ changed = true ;
1261+ }
1262+ }
1263+ if ( Array . isArray ( node . args ) ) for ( const child of node . args ) visit ( child ) ;
1264+ } ;
1265+ visit ( ast ) ;
1266+ return changed ? serialize ( ast as Parameters < typeof serialize > [ 0 ] ) : null ;
11061267}
11071268
11081269/**
@@ -1303,14 +1464,17 @@ export const celEngine: DialectEngine = {
13031464 // date/datetime fields (`end_date` → `"2026-06-20"`) on
13041465 // `record.end_date <= daysFromNow(60)` (#1530), since cel-js compares the
13051466 // raw string against the `google.protobuf.Timestamp` from `today()` etc.
1306- // Hydrate those strings to number / Date and retry ONCE. This only runs
1307- // after a fault, so a comparison that already evaluated cleanly is never
1308- // re-interpreted; if the retry still cannot type-check, the original loud
1309- // error is reported.
1467+ // Coerce those operands — and ONLY those — and retry ONCE. #7098: the
1468+ // coercion is per operand POSITION, not scope-wide, so a comparison that
1469+ // already evaluated cleanly is never re-interpreted; the scope itself is
1470+ // never rewritten, so a numeric-looking string RETURNED by the expression
1471+ // keeps its type too. When no operand provably faulted, or the retry still
1472+ // cannot type-check, the original loud error is reported.
13101473 if ( ! isNumericOverloadError ( err ) ) throw err ;
1311- const hydrated = hydrateOverloadStrings ( scope ) as Record < string , unknown > ;
1474+ const coercedSource = rewriteFaultedOperands ( evalSource , scope ) ;
1475+ if ( coercedSource === null ) throw err ;
13121476 try {
1313- const raw = env . evaluate ( evalSource , hydrated ) ;
1477+ const raw = env . evaluate ( coercedSource , scope ) ;
13141478 return { ok : true , value : coerce ( raw ) as T } ;
13151479 } catch {
13161480 // Hydration did not resolve it — surface the original fault, not the
0 commit comments