@@ -341,7 +341,13 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
341341 close_date : { type : 'date' } ,
342342 expected : { type : 'formula' , formula : 'record.amount * record.probability / 100' } ,
343343 } ,
344- validations : [ { name : 'future' , expression : 'record.close_date >= today()' } ] ,
344+ // The `!= null` guard is load-bearing since #4763: `close_date` is a
345+ // declared NULLABLE field, and an un-guarded `>=` over it faults at
346+ // runtime (`null >= timestamp` has no overload) — the null-guard gate
347+ // rejects that shape at authoring now. Soundness (this block's
348+ // subject) and null-guarding are separate verdicts; the predicate has
349+ // to satisfy both to produce zero issues.
350+ validations : [ { name : 'future' , expression : 'record.close_date != null && record.close_date >= today()' } ] ,
345351 } ] ,
346352 } ) ;
347353 expect ( issues ) . toHaveLength ( 0 ) ;
@@ -758,3 +764,203 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
758764 } ) ;
759765 } ) ;
760766} ) ;
767+
768+ // ───────────────────────────────────────────────────────────────────────
769+ // #4763 — `has(x)` reads as a null guard and is not one.
770+ //
771+ // Scope note (constraint of the issue, pinned here so it stays a decision):
772+ // this gate walks the AUTHORED METADATA the stack carries — object validation
773+ // rules and lifecycle-hook conditions. It never reads source files, so the
774+ // deliberately-bad fixtures in `packages/objectql/src/validation/rule-*.test.ts`
775+ // (which pin the runtime's fail-closed behaviour and MUST keep the bad shape)
776+ // are structurally out of its reach.
777+ // ───────────────────────────────────────────────────────────────────────
778+ describe ( 'null-guard gate (#4763)' , ( ) => {
779+ // Mirrors `showcase_project`: dates and money are declared but nullable;
780+ // `status` carries a default option and `name` is required, so neither can
781+ // be null and neither may ever be flagged.
782+ const project = {
783+ name : 'showcase_project' ,
784+ fields : {
785+ name : { type : 'text' , required : true } ,
786+ status : { type : 'select' , options : [ { value : 'planned' , default : true } , { value : 'active' } ] } ,
787+ start_date : { type : 'date' } ,
788+ end_date : { type : 'date' } ,
789+ budget : { type : 'currency' } ,
790+ spent : { type : 'currency' , defaultValue : 0 } ,
791+ } ,
792+ } ;
793+ const withRule = ( rule : Record < string , unknown > ) =>
794+ validateStackExpressions ( { objects : [ { ...project , validations : [ rule ] } ] } ) ;
795+
796+ it ( 'REJECTS the `has(a) && has(b) && a < b` shape over nullable declared fields' , ( ) => {
797+ const issues = withRule ( {
798+ type : 'script' ,
799+ name : 'end_after_start' ,
800+ condition : 'has(record.start_date) && has(record.end_date) && record.end_date < record.start_date' ,
801+ } ) ;
802+ expect ( issues . length ) . toBeGreaterThan ( 0 ) ;
803+ expect ( issues . every ( ( i ) => ( i . severity ?? 'error' ) === 'error' ) ) . toBe ( true ) ;
804+ const joined = issues . map ( ( i ) => i . message ) . join ( '\n' ) ;
805+ // names the rule …
806+ expect ( joined ) . toContain ( "validation rule 'end_after_start'" ) ;
807+ // … the operand …
808+ expect ( joined ) . toContain ( 'record.end_date' ) ;
809+ expect ( joined ) . toContain ( 'record.start_date' ) ;
810+ // … and the fix, in the runtime's own words.
811+ expect ( joined ) . toContain ( "Guard it with '!= null'" ) ;
812+ expect ( joined ) . toContain ( 'has(x)' ) ;
813+ expect ( issues [ 0 ] . where ) . toContain ( "object 'showcase_project'" ) ;
814+ } ) ;
815+
816+ it ( 'ACCEPTS the `!= null` form (the fix #4761 landed in the examples)' , ( ) => {
817+ expect (
818+ withRule ( {
819+ type : 'script' ,
820+ name : 'end_after_start' ,
821+ condition :
822+ 'record.start_date != null && record.end_date != null && record.end_date < record.start_date' ,
823+ } ) ,
824+ ) . toHaveLength ( 0 ) ;
825+ } ) ;
826+
827+ it ( 'ACCEPTS a guarded arithmetic predicate (showcase `spent_within_budget`)' , ( ) => {
828+ expect (
829+ withRule ( {
830+ type : 'script' ,
831+ name : 'spent_within_budget' ,
832+ condition : 'record.budget != null && record.spent != null && record.spent > record.budget * 1.2' ,
833+ } ) ,
834+ ) . toHaveLength ( 0 ) ;
835+ } ) ;
836+
837+ it ( 'never flags a required field or one with a default (`spent`, `status`, `name`)' , ( ) => {
838+ expect (
839+ withRule ( { type : 'script' , name : 'spend_positive' , condition : 'record.spent > 0' } ) ,
840+ ) . toHaveLength ( 0 ) ;
841+ } ) ;
842+
843+ it ( 'reaches the predicates nested in a `conditional` rule’s then/otherwise' , ( ) => {
844+ const issues = withRule ( {
845+ type : 'conditional' ,
846+ name : 'budget_sanity' ,
847+ when : "record.status == 'active'" ,
848+ then : { type : 'script' , name : 'over_budget' , condition : 'has(record.budget) && record.budget > 1' } ,
849+ } ) ;
850+ expect ( issues . length ) . toBe ( 1 ) ;
851+ expect ( issues [ 0 ] . message ) . toContain ( 'record.budget' ) ;
852+ expect ( issues [ 0 ] . where ) . toContain ( "'budget_sanity' then → 'over_budget'" ) ;
853+ } ) ;
854+
855+ // Negative-case pin: the real `showcase_account` rule pair. Both use `has()`
856+ // — legitimately, to tell "key absent from the PATCH" apart from "explicit
857+ // null" — and both compare with EQUALITY only. They must stay legal; a rule
858+ // that flags them is too broad.
859+ it ( 'leaves `showcase_account.churn_reason_consistency` alone (legitimate `has()`)' , ( ) => {
860+ const issues = validateStackExpressions ( {
861+ objects : [ {
862+ name : 'showcase_account' ,
863+ fields : { status : { type : 'select' , options : [ { value : 'churned' } ] } , churn_reason : { type : 'text' } } ,
864+ validations : [ {
865+ type : 'conditional' ,
866+ name : 'churn_reason_consistency' ,
867+ when : "record.status == 'churned'" ,
868+ then : {
869+ type : 'script' ,
870+ name : 'churn_reason_present' ,
871+ condition : "!has(record.churn_reason) || record.churn_reason == null || record.churn_reason == ''" ,
872+ } ,
873+ otherwise : {
874+ type : 'script' ,
875+ name : 'churn_reason_absent' ,
876+ condition : "has(record.churn_reason) && record.churn_reason != null && record.churn_reason != ''" ,
877+ } ,
878+ } ] ,
879+ } ] ,
880+ } ) ;
881+ expect ( issues ) . toHaveLength ( 0 ) ;
882+ } ) ;
883+
884+ describe ( 'hook conditions — the third instance the issue named' , ( ) => {
885+ const hookStack = ( condition : string ) => ( {
886+ objects : [ project ] ,
887+ hooks : [ { name : 'project_budget_alert' , object : 'showcase_project' , condition } ] ,
888+ } ) ;
889+
890+ // Regression pin. `examples/app-showcase/src/data/hooks/index.ts` carried
891+ // `has(record.spent) && has(record.budget) && record.spent > record.budget`
892+ // until #4770/#4786 corrected it. This asserts the bad shape cannot come
893+ // back: it is red today, and would have been red before that fix.
894+ it ( 'REJECTS the pre-#4786 showcase hook shape' , ( ) => {
895+ const issues = validateStackExpressions (
896+ hookStack ( 'has(record.spent) && has(record.budget) && record.spent > record.budget' ) ,
897+ ) ;
898+ expect ( issues . length ) . toBeGreaterThan ( 0 ) ;
899+ expect ( issues [ 0 ] . where ) . toContain ( "hook 'project_budget_alert'" ) ;
900+ expect ( issues . map ( ( i ) => i . message ) . join ( '\n' ) ) . toContain ( 'record.budget' ) ;
901+ } ) ;
902+
903+ it ( 'ACCEPTS the corrected shape now on `main`' , ( ) => {
904+ expect (
905+ validateStackExpressions (
906+ hookStack ( 'record.spent != null && record.budget != null && record.spent > record.budget' ) ,
907+ ) ,
908+ ) . toHaveLength ( 0 ) ;
909+ } ) ;
910+
911+ it ( 'applies per target for a multi-object hook' , ( ) => {
912+ const issues = validateStackExpressions ( {
913+ objects : [ project , { name : 'other_obj' , fields : { budget : { type : 'currency' , required : true } } } ] ,
914+ hooks : [ { name : 'multi' , object : [ 'showcase_project' , 'other_obj' ] , condition : 'record.budget > 1' } ] ,
915+ } ) ;
916+ // Only the object that declares `budget` nullable is flagged.
917+ expect ( issues ) . toHaveLength ( 1 ) ;
918+ expect ( issues [ 0 ] . where ) . toContain ( 'showcase_project' ) ;
919+ } ) ;
920+ } ) ;
921+
922+ describe ( 'surfaces deliberately NOT covered' , ( ) => {
923+ it ( 'leaves sharing-rule conditions alone (compiled to a SQL filter, never faults)' , ( ) => {
924+ expect (
925+ validateStackExpressions ( {
926+ objects : [ project ] ,
927+ sharingRules : [ {
928+ name : 'big_budget' ,
929+ object : 'showcase_project' ,
930+ condition : "record.status == 'active' && record.budget > 100000" ,
931+ } ] ,
932+ } ) ,
933+ ) . toHaveLength ( 0 ) ;
934+ } ) ;
935+
936+ it ( 'leaves flattened flow conditions alone (a bare id may be a flow variable)' , ( ) => {
937+ expect (
938+ validateStackExpressions ( {
939+ objects : [ project ] ,
940+ flows : [ {
941+ name : 'escalate' ,
942+ nodes : [
943+ { id : 'start' , type : 'start' , config : { objectName : 'showcase_project' } } ,
944+ { id : 'd' , type : 'decision' , config : { condition : 'record.budget > 100000' } } ,
945+ ] ,
946+ edges : [ ] ,
947+ } ] ,
948+ } ) ,
949+ ) . toHaveLength ( 0 ) ;
950+ } ) ;
951+
952+ it ( 'leaves `Field.formula` expressions alone (blessed `guard ? value : null`, #3306)' , ( ) => {
953+ expect (
954+ validateStackExpressions ( {
955+ objects : [ {
956+ ...project ,
957+ fields : {
958+ ...project . fields ,
959+ remaining : { type : 'formula' , formula : 'record.budget - record.spent' } ,
960+ } ,
961+ } ] ,
962+ } ) ,
963+ ) . toHaveLength ( 0 ) ;
964+ } ) ;
965+ } ) ;
966+ } ) ;
0 commit comments