@@ -260,6 +260,212 @@ async def test_starts_with_supports_list_input_with_all_logic(self) -> None:
260260 assert matching_result .matched is True
261261 assert partial_result .matched is False
262262
263+ @pytest .mark .asyncio
264+ async def test_ends_with_matches_suffix (self ) -> None :
265+ """Test that ends_with mode triggers on suffix matches."""
266+ # Given: an ends_with evaluator config
267+ evaluator = ListEvaluator (
268+ ListEvaluatorConfig (
269+ values = ["/SOUL.md" , ".py" ],
270+ logic = "any" ,
271+ match_on = "match" ,
272+ match_mode = "ends_with" ,
273+ case_sensitive = True ,
274+ )
275+ )
276+
277+ # When: evaluating a path with an allowed suffix
278+ result = await evaluator .evaluate ("/home/lev/agent-control/SOUL.md" )
279+
280+ # Then: the suffix match triggers
281+ assert result .matched is True
282+ assert result .metadata ["matches" ] == ["/home/lev/agent-control/SOUL.md" ]
283+
284+ @pytest .mark .asyncio
285+ async def test_ends_with_matches_exact_path_value (self ) -> None :
286+ """Test that ends_with mode matches the configured path value exactly."""
287+ # Given: an ends_with evaluator config
288+ evaluator = ListEvaluator (
289+ ListEvaluatorConfig (
290+ values = ["/home/lev/agent-control/SOUL.md" ],
291+ logic = "any" ,
292+ match_on = "match" ,
293+ match_mode = "ends_with" ,
294+ case_sensitive = True ,
295+ )
296+ )
297+
298+ # When: evaluating the exact configured path
299+ result = await evaluator .evaluate ("/home/lev/agent-control/SOUL.md" )
300+
301+ # Then: the exact path matches
302+ assert result .matched is True
303+ assert result .metadata ["matches" ] == ["/home/lev/agent-control/SOUL.md" ]
304+
305+ @pytest .mark .asyncio
306+ async def test_ends_with_no_match_when_suffix_absent (self ) -> None :
307+ """Test that ends_with mode does not trigger when no suffix matches."""
308+ # Given: an ends_with evaluator config
309+ evaluator = ListEvaluator (
310+ ListEvaluatorConfig (
311+ values = ["/SOUL.md" , ".py" ],
312+ logic = "any" ,
313+ match_on = "match" ,
314+ match_mode = "ends_with" ,
315+ case_sensitive = True ,
316+ )
317+ )
318+
319+ # When: evaluating a path with no configured suffix
320+ result = await evaluator .evaluate ("/var/log/system.log" )
321+
322+ # Then: the evaluator does not trigger
323+ assert result .matched is False
324+
325+ @pytest .mark .asyncio
326+ async def test_ends_with_honors_case_sensitivity (self ) -> None :
327+ """Test that ends_with mode respects case sensitivity settings."""
328+ # Given: two ends_with evaluators that differ only by case sensitivity
329+ insensitive = ListEvaluator (
330+ ListEvaluatorConfig (
331+ values = ["/SOUL.MD" ],
332+ logic = "any" ,
333+ match_on = "match" ,
334+ match_mode = "ends_with" ,
335+ case_sensitive = False ,
336+ )
337+ )
338+ sensitive = ListEvaluator (
339+ ListEvaluatorConfig (
340+ values = ["/SOUL.MD" ],
341+ logic = "any" ,
342+ match_on = "match" ,
343+ match_mode = "ends_with" ,
344+ case_sensitive = True ,
345+ )
346+ )
347+
348+ # When: evaluating the same lower-case path against both
349+ insensitive_result = await insensitive .evaluate ("/home/lev/agent-control/SOUL.md" )
350+ sensitive_result = await sensitive .evaluate ("/home/lev/agent-control/SOUL.md" )
351+
352+ # Then: only the case-insensitive evaluator matches
353+ assert insensitive_result .matched is True
354+ assert sensitive_result .matched is False
355+
356+ @pytest .mark .asyncio
357+ async def test_ends_with_supports_no_match_allowlists (self ) -> None :
358+ """Test that ends_with works with no_match for allowlist-style controls."""
359+ # Given: an ends_with evaluator configured as an allowlist
360+ evaluator = ListEvaluator (
361+ ListEvaluatorConfig (
362+ values = [".md" , ".txt" ],
363+ logic = "any" ,
364+ match_on = "no_match" ,
365+ match_mode = "ends_with" ,
366+ case_sensitive = True ,
367+ )
368+ )
369+
370+ # When: evaluating one allowed and one disallowed path
371+ allowed_result = await evaluator .evaluate ("/home/lev/agent-control/SOUL.md" )
372+ denied_result = await evaluator .evaluate ("/var/log/system.log" )
373+
374+ # Then: only the disallowed path triggers the control
375+ assert allowed_result .matched is False
376+ assert denied_result .matched is True
377+
378+ @pytest .mark .asyncio
379+ async def test_ends_with_matches_plain_text_suffix (self ) -> None :
380+ """Test that ends_with mode works for ordinary non-path strings."""
381+ # Given: an ends_with evaluator config for ordinary strings
382+ evaluator = ListEvaluator (
383+ ListEvaluatorConfig (
384+ values = ["-control" , ":done" ],
385+ logic = "any" ,
386+ match_on = "match" ,
387+ match_mode = "ends_with" ,
388+ case_sensitive = True ,
389+ )
390+ )
391+
392+ # When: evaluating values with and without the configured suffixes
393+ matched_result = await evaluator .evaluate ("agent-control" )
394+ unmatched_result = await evaluator .evaluate ("done: please" )
395+
396+ # Then: only the true suffix match triggers
397+ assert matched_result .matched is True
398+ assert unmatched_result .matched is False
399+
400+ @pytest .mark .asyncio
401+ async def test_ends_with_escapes_regex_metacharacters (self ) -> None :
402+ """Test that ends_with treats configured values as literals, not regex."""
403+ # Given: suffixes containing regex metacharacters
404+ evaluator = ListEvaluator (
405+ ListEvaluatorConfig (
406+ values = [".tar.gz+" , "[beta]" ],
407+ logic = "any" ,
408+ match_on = "match" ,
409+ match_mode = "ends_with" ,
410+ case_sensitive = True ,
411+ )
412+ )
413+
414+ # When: evaluating values that end with those literal suffixes
415+ archive_result = await evaluator .evaluate ("release-1.tar.gz+" )
416+ beta_result = await evaluator .evaluate ("feature-[beta]" )
417+
418+ # Then: both values match literally
419+ assert archive_result .matched is True
420+ assert beta_result .matched is True
421+
422+ @pytest .mark .asyncio
423+ async def test_ends_with_supports_list_input_with_any_logic (self ) -> None :
424+ """Test that ends_with works on list inputs when any item matches."""
425+ # Given: an ends_with evaluator with any-item semantics
426+ evaluator = ListEvaluator (
427+ ListEvaluatorConfig (
428+ values = [".md" , "-control" ],
429+ logic = "any" ,
430+ match_on = "match" ,
431+ match_mode = "ends_with" ,
432+ case_sensitive = True ,
433+ )
434+ )
435+
436+ # When: evaluating a list where only one element matches
437+ result = await evaluator .evaluate (["/var/log/system.log" , "agent-control" ])
438+
439+ # Then: the evaluator triggers and reports the matching entry
440+ assert result .matched is True
441+ assert result .metadata ["matches" ] == ["agent-control" ]
442+
443+ @pytest .mark .asyncio
444+ async def test_ends_with_supports_list_input_with_all_logic (self ) -> None :
445+ """Test that ends_with respects all-item semantics for list inputs."""
446+ # Given: an ends_with evaluator with all-item semantics
447+ evaluator = ListEvaluator (
448+ ListEvaluatorConfig (
449+ values = [".md" , ".txt" ],
450+ logic = "all" ,
451+ match_on = "match" ,
452+ match_mode = "ends_with" ,
453+ case_sensitive = True ,
454+ )
455+ )
456+
457+ # When: evaluating one fully matching list and one partially matching list
458+ matching_result = await evaluator .evaluate (
459+ ["/home/lev/agent-control/SOUL.md" , "/tmp/cache/notes.txt" ]
460+ )
461+ partial_result = await evaluator .evaluate (
462+ ["/home/lev/agent-control/SOUL.md" , "/var/log/system.log" ]
463+ )
464+
465+ # Then: only the fully matching list triggers
466+ assert matching_result .matched is True
467+ assert partial_result .matched is False
468+
263469 @pytest .mark .asyncio
264470 async def test_legacy_empty_string_value_is_ignored_defensively (self ) -> None :
265471 """Test that legacy invalid configs do not compile into a match-all regex."""
0 commit comments