@@ -20,6 +20,7 @@ import {
2020 FLOW_DEFAULT_EDGE_WITH_CONDITION ,
2121 FLOW_MULTIPLE_DEFAULT_EDGES ,
2222 FLOW_INERT_NODE_CONDITION ,
23+ FLOW_MULTI_WRITE_UNFILTERED ,
2324} from './lint-flow-patterns.js' ;
2425
2526const CEL = ( source : string ) => ( { dialect : 'cel' , source } ) ;
@@ -995,3 +996,219 @@ describe('#5383 — a recursive config scan does not double-report the container
995996 expect ( fnds [ 0 ] . where ) . not . toContain ( "node 'loop_leads'" ) ;
996997 } ) ;
997998} ) ;
999+
1000+ /**
1001+ * #5482 — the declared WHOLE-OBJECT write: `multi: true` on a
1002+ * `delete_record` / `update_record` with nothing bounding it.
1003+ *
1004+ * Reachable only since #5393 gave these nodes a bulk declaration: before it the
1005+ * executor never passed `options.multi`, the engine refused every predicate
1006+ * write, and "empty filter + bulk" was not an authoring surface at all. Measured
1007+ * on `origin/main` before this rule existed, all four shapes below — top-level
1008+ * delete, empty-object filter, update, and the same node inside a `loop` body —
1009+ * returned `[]` from `lintFlowPatterns`. The only feedback an author got was the
1010+ * step's `acted` row count, after the rows were gone.
1011+ */
1012+
1013+ /** A janitor flow: one bulk write node, scheduled, correctly `runAs: 'system'`. */
1014+ function purgeFlow ( nodeType : string , config : unknown ) {
1015+ return {
1016+ flows : [ {
1017+ name : 'nightly_purge' ,
1018+ runAs : 'system' ,
1019+ nodes : [
1020+ { id : 'start' , type : 'start' , config : { triggerType : 'schedule' , schedule : 'cron:0 3 * * *' } } ,
1021+ { id : 'purge' , type : nodeType , config } ,
1022+ ] ,
1023+ edges : [ { id : 'e1' , source : 'start' , target : 'purge' } ] ,
1024+ } ] ,
1025+ } ;
1026+ }
1027+
1028+ describe ( 'lintFlowPatterns — unbounded bulk write (#5482)' , ( ) => {
1029+ it ( 'flags a delete_record with `multi: true` and NO filter' , ( ) => {
1030+ const fnds = lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , multi : true } ) ) ;
1031+ expect ( fnds ) . toHaveLength ( 1 ) ;
1032+ expect ( fnds [ 0 ] . rule ) . toBe ( FLOW_MULTI_WRITE_UNFILTERED ) ;
1033+ expect ( fnds [ 0 ] . where ) . toBe ( "flow 'nightly_purge' · node 'purge' (delete_record)" ) ;
1034+ // Advisory: the engine's dispatch table grants "bulk intent, no predicate"
1035+ // on purpose, so the shape is not provably wrong (severity policy at the top
1036+ // of lint-flow-patterns.ts). `undefined` is how this family spells warning.
1037+ expect ( fnds [ 0 ] . severity ) . toBeUndefined ( ) ;
1038+ // Says WHAT it does — the object by name, and that it is every row.
1039+ expect ( fnds [ 0 ] . message ) . toContain ( 'no `filter` key' ) ;
1040+ expect ( fnds [ 0 ] . message ) . toContain ( 'WHOLE-OBJECT write' ) ;
1041+ expect ( fnds [ 0 ] . message ) . toContain ( "every row of 'lead' is deleted" ) ;
1042+ expect ( fnds [ 0 ] . message ) . toContain ( 'driver.deleteMany' ) ;
1043+ // The authority it cites is the delete dispatch that is actually extracted
1044+ // and case-set-pinned — not a hand-waved "the engine allows it".
1045+ expect ( fnds [ 0 ] . message ) . toContain ( 'delete-dispatch case-set' ) ;
1046+ expect ( fnds [ 0 ] . message ) . toContain ( 'multi with no predicate at all' ) ;
1047+ // …and that the only run-time feedback arrives too late to help.
1048+ expect ( fnds [ 0 ] . message ) . toMatch ( / ` a c t e d ` r o w c o u n t / ) ;
1049+ expect ( fnds [ 0 ] . message ) . toMatch ( / A F T E R t h e r o w s a r e g o n e / ) ;
1050+ } ) ;
1051+
1052+ it ( 'flags an EMPTY filter the same way, and says which of the two it saw' , ( ) => {
1053+ const fnds = lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , filter : { } , multi : true } ) ) ;
1054+ expect ( fnds ) . toHaveLength ( 1 ) ;
1055+ expect ( fnds [ 0 ] . rule ) . toBe ( FLOW_MULTI_WRITE_UNFILTERED ) ;
1056+ expect ( fnds [ 0 ] . message ) . toContain ( 'an EMPTY `filter`' ) ;
1057+ expect ( fnds [ 0 ] . message ) . not . toContain ( 'no `filter` key' ) ;
1058+ } ) ;
1059+
1060+ it ( 'flags an update_record too, in the words of an overwrite' , ( ) => {
1061+ const fnds = lintFlowPatterns (
1062+ purgeFlow ( 'update_record' , { objectName : 'lead' , fields : { status : 'stale' } , multi : true } ) ,
1063+ ) ;
1064+ expect ( fnds ) . toHaveLength ( 1 ) ;
1065+ expect ( fnds [ 0 ] . rule ) . toBe ( FLOW_MULTI_WRITE_UNFILTERED ) ;
1066+ expect ( fnds [ 0 ] . where ) . toBe ( "flow 'nightly_purge' · node 'purge' (update_record)" ) ;
1067+ expect ( fnds [ 0 ] . message ) . toContain ( "every row of 'lead' is overwritten" ) ;
1068+ expect ( fnds [ 0 ] . message ) . toContain ( 'driver.updateMany' ) ;
1069+ // Update has no extracted dispatch module, so the message cites the branch
1070+ // itself rather than borrowing delete's case-set.
1071+ expect ( fnds [ 0 ] . message ) . toContain ( 'bulk branch on `options.multi`' ) ;
1072+ expect ( fnds [ 0 ] . message ) . not . toContain ( 'delete-dispatch case-set' ) ;
1073+ } ) ;
1074+
1075+ it ( 'names the #3810 run-time guard and says the two judge DIFFERENT facts' , ( ) => {
1076+ const [ f ] = lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , multi : true } ) ) ;
1077+ // Cross-naming, not duplication: the run-time guard refuses "a condition you
1078+ // WROTE is gone"; this rule warns "no condition was ever written".
1079+ expect ( f . hint ) . toContain ( '#3810' ) ;
1080+ expect ( f . hint ) . toMatch ( / R E F U S E S t h i s n o d e a t r u n t i m e / ) ;
1081+ expect ( f . hint ) . toMatch ( / a w r i t t e n c o n d i t i o n i s g o n e / ) ;
1082+ expect ( f . hint ) . toMatch ( / t h e f i l t e r i s e m p t y / ) ;
1083+ // Both ways out are offered, and the run-time path is explicitly NOT closed.
1084+ expect ( f . hint ) . toMatch ( / W r i t e t h e c o n s t r a i n t y o u m e a n / ) ;
1085+ expect ( f . hint ) . toMatch ( / w a r n i n g , n o t a g a t e / ) ;
1086+ expect ( f . hint ) . toContain ( 'showcase_inquiry_purge' ) ;
1087+ } ) ;
1088+
1089+ describe ( 'does NOT flag (false-positive guards)' , ( ) => {
1090+ it ( 'a bulk write BOUNDED by a filter — the showcase purge shape' , ( ) => {
1091+ expect (
1092+ lintFlowPatterns ( purgeFlow ( 'delete_record' , {
1093+ objectName : 'showcase_inquiry' , filter : { status : 'closed' } , multi : true ,
1094+ } ) ) ,
1095+ ) . toHaveLength ( 0 ) ;
1096+ } ) ;
1097+
1098+ it ( 'a filter whose only condition is a TEMPLATE — that is #3810\'s fact, at run time' , ( ) => {
1099+ // `{record.ownr}` (a typo) interpolates to nothing and the run-time guard
1100+ // REFUSES the node. At authoring time the condition is written, so warning
1101+ // "nothing bounds this" here would be false — and would put two diagnostics
1102+ // on one defect, one of them wrong about what the author did.
1103+ expect (
1104+ lintFlowPatterns ( purgeFlow ( 'delete_record' , {
1105+ objectName : 'lead' , filter : { owner : '{record.ownr}' } , multi : true ,
1106+ } ) ) ,
1107+ ) . toHaveLength ( 0 ) ;
1108+ } ) ;
1109+
1110+ it ( 'no `multi` at all — the engine refuses that call BY NAME already' , ( ) => {
1111+ // `Delete requires an ID or options.multi=true`. Nothing silent to warn
1112+ // about, and #5482 is scoped to the declared-bulk shape.
1113+ expect ( lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' } ) ) ) . toHaveLength ( 0 ) ;
1114+ expect ( lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , filter : { } } ) ) ) . toHaveLength ( 0 ) ;
1115+ } ) ;
1116+
1117+ it ( '`multi: false` — the declaration says the opposite' , ( ) => {
1118+ expect ( lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , multi : false } ) ) ) . toHaveLength ( 0 ) ;
1119+ } ) ;
1120+
1121+ it ( '`multi: \'true\'` (a string) — the schema refuses the node, so it cannot run' , ( ) => {
1122+ // The executor tests `cfg.multi === true` and the schema types the key
1123+ // `z.boolean()`; a string is a parse refusal, not declared bulk intent.
1124+ expect ( lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , multi : 'true' } ) ) ) . toHaveLength ( 0 ) ;
1125+ } ) ;
1126+
1127+ it ( 'a node type that carries no `multi` declaration' , ( ) => {
1128+ // `get_record` does not write and has no bulk intent; `create_record` has
1129+ // neither `filter` nor `multi`. A stray key there is the schema's business.
1130+ expect ( lintFlowPatterns ( purgeFlow ( 'get_record' , { objectName : 'lead' , multi : true } ) ) ) . toHaveLength ( 0 ) ;
1131+ expect ( lintFlowPatterns ( purgeFlow ( 'create_record' , { objectName : 'lead' , multi : true } ) ) ) . toHaveLength ( 0 ) ;
1132+ } ) ;
1133+
1134+ it ( 'a non-object `filter` — refused by name at execute time, so no run to describe' , ( ) => {
1135+ expect (
1136+ lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , filter : 'status = closed' , multi : true } ) ) ,
1137+ ) . toHaveLength ( 0 ) ;
1138+ } ) ;
1139+
1140+ it ( 'an empty COMBINATOR array — deliberately out of range, both directions' , ( ) => {
1141+ // #5322/#5134 ruled these and every driver implements the ruling: `$and: []`
1142+ // is TRUE (this one IS a whole-object write and goes unwarned — filed as a
1143+ // follow-up), `$or: []` is FALSE (matches nothing — warning about it would
1144+ // be a false alarm). Telling them apart needs the identity REDUCTION, which
1145+ // already exists three times producer-side; a fourth hand-written copy in a
1146+ // linter is the divergence `engine-delete-dispatch.ts` exists to prevent.
1147+ expect (
1148+ lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , filter : { $and : [ ] } , multi : true } ) ) ,
1149+ ) . toHaveLength ( 0 ) ;
1150+ expect (
1151+ lintFlowPatterns ( purgeFlow ( 'delete_record' , { objectName : 'lead' , filter : { $or : [ ] } , multi : true } ) ) ,
1152+ ) . toHaveLength ( 0 ) ;
1153+ } ) ;
1154+ } ) ;
1155+
1156+ /**
1157+ * The rule's main habitat. A scheduled sweep whose per-item work sits in a
1158+ * `loop` body is the standard shape for a janitor flow, so a rule that only
1159+ * saw top-level nodes would miss the case it was written for — the #5383/#5635
1160+ * blind spot, in the exact family that closed it.
1161+ */
1162+ describe ( 'inside a nested region (#5383 / #5635)' , ( ) => {
1163+ it ( 'flags a loop-body sweep, scoped to the region, exactly once' , ( ) => {
1164+ const fnds = lintFlowPatterns ( loopBodyFlow ( {
1165+ nodes : [
1166+ { id : 'sweep' , type : 'delete_record' , config : { objectName : 'campaign_member' , multi : true } } ,
1167+ ] ,
1168+ edges : [ ] ,
1169+ } ) ) ;
1170+ expect ( fnds ) . toHaveLength ( 1 ) ;
1171+ expect ( fnds [ 0 ] . rule ) . toBe ( FLOW_MULTI_WRITE_UNFILTERED ) ;
1172+ expect ( fnds [ 0 ] . where ) . toBe (
1173+ "flow 'campaign_enrollment' · loop 'loop_leads' body · node 'sweep' (delete_record)" ,
1174+ ) ;
1175+ // Not attributed to the enclosing container: the `loop`'s own config
1176+ // CONTAINS the body, but this rule reads named keys (`multi`, `filter`) off
1177+ // each node, and a `loop` declares neither — so there is no second copy.
1178+ expect ( fnds [ 0 ] . where ) . not . toContain ( "node 'loop_leads'" ) ;
1179+ expect ( fnds [ 0 ] . message ) . toContain ( "every row of 'campaign_member' is deleted" ) ;
1180+ } ) ;
1181+
1182+ it ( 'flags an update_record two regions deep' , ( ) => {
1183+ const fnds = lintFlowPatterns ( loopBodyFlow ( {
1184+ nodes : [ {
1185+ id : 'loop_touchpoints' , type : 'loop' , label : 'Loop Touchpoints' ,
1186+ config : {
1187+ collection : '{lead.touchpoints}' , itemVar : 'tp' ,
1188+ body : {
1189+ nodes : [ { id : 'reset' , type : 'update_record' , config : { objectName : 'touchpoint' , fields : { done : false } , multi : true } } ] ,
1190+ edges : [ ] ,
1191+ } ,
1192+ } ,
1193+ } ] ,
1194+ edges : [ ] ,
1195+ } ) ) ;
1196+ expect ( fnds ) . toHaveLength ( 1 ) ;
1197+ expect ( fnds [ 0 ] . rule ) . toBe ( FLOW_MULTI_WRITE_UNFILTERED ) ;
1198+ expect ( fnds [ 0 ] . where ) . toBe (
1199+ "flow 'campaign_enrollment' · loop 'loop_leads' body → loop 'loop_touchpoints' body · " +
1200+ "node 'reset' (update_record)" ,
1201+ ) ;
1202+ } ) ;
1203+
1204+ it ( 'leaves a BOUNDED loop-body sweep alone' , ( ) => {
1205+ expect ( lintFlowPatterns ( loopBodyFlow ( {
1206+ nodes : [ {
1207+ id : 'sweep' , type : 'delete_record' ,
1208+ config : { objectName : 'campaign_member' , filter : { lead_id : '{lead.id}' } , multi : true } ,
1209+ } ] ,
1210+ edges : [ ] ,
1211+ } ) ) ) . toHaveLength ( 0 ) ;
1212+ } ) ;
1213+ } ) ;
1214+ } ) ;
0 commit comments