@@ -1262,3 +1262,211 @@ def test_validate_from_path_codex_backend_valid_after_pruning(tmp_path: Path) ->
12621262 if s .get ("severity" ) == Severity .ERROR
12631263 )
12641264 )
1265+
1266+
1267+ # ---------------------------------------------------------------------------
1268+ # load_and_validate fail-closed tests (regression for steps-less YAML bug)
1269+ # ---------------------------------------------------------------------------
1270+
1271+
1272+ _RECIPE_NO_STEPS = """\
1273+ name: test-no-steps
1274+ description: A test recipe with no steps key
1275+ autoskillit_version: "0.3.0"
1276+ kitchen_rules:
1277+ - "Test rule"
1278+ """
1279+
1280+ _RECIPE_EMPTY_STEPS = """\
1281+ name: test-empty-steps
1282+ description: A test recipe with empty steps
1283+ autoskillit_version: "0.3.0"
1284+ kitchen_rules:
1285+ - "Test rule"
1286+ steps: {}
1287+ """
1288+
1289+ _RECIPE_NON_DICT_STEPS = """\
1290+ name: test-list-steps
1291+ description: A test recipe with steps as a list
1292+ autoskillit_version: "0.3.0"
1293+ kitchen_rules:
1294+ - "Test rule"
1295+ steps:
1296+ - foo
1297+ - bar
1298+ """
1299+
1300+ _RECIPE_NON_DICT_ROOT = """\
1301+ - item1
1302+ - item2
1303+ """
1304+
1305+ _RECIPE_CAMPAIGN_NO_STEPS = """\
1306+ name: test-campaign-no-steps
1307+ description: A campaign recipe with dispatches and no steps
1308+ autoskillit_version: "0.3.0"
1309+ kind: campaign
1310+ kitchen_rules:
1311+ - "Campaign rule"
1312+ dispatches:
1313+ - name: dispatch1
1314+ recipe: some-recipe
1315+ gate: some_gate
1316+ """
1317+
1318+
1319+ # 1a: steps-less YAML returns valid=False
1320+ def test_load_and_validate_steps_less_yaml_returns_invalid (tmp_path : Path ) -> None :
1321+ """A YAML recipe without a 'steps' key must produce valid=False."""
1322+ import autoskillit .recipe ._api_cache as cache_mod
1323+
1324+ cache_mod ._LOAD_CACHE .clear ()
1325+ _setup_project_recipe (tmp_path , "test-no-steps" , _RECIPE_NO_STEPS )
1326+
1327+ from autoskillit .recipe ._api import load_and_validate
1328+
1329+ result = load_and_validate ("test-no-steps" , project_dir = tmp_path )
1330+
1331+ assert result ["valid" ] is False , (
1332+ f"Steps-less YAML must return valid=False; got valid={ result .get ('valid' )} . "
1333+ f"Errors: { result .get ('errors' , [])} "
1334+ )
1335+ assert len (result ["errors" ]) > 0 , "Expected at least one error for steps-less YAML"
1336+ assert any ("step" in e .lower () for e in result ["errors" ]), (
1337+ f"Expected an error mentioning steps; got: { result ['errors' ]} "
1338+ )
1339+ assert any (s .get ("severity" ) == Severity .ERROR for s in result .get ("suggestions" , [])), (
1340+ "Expected at least one ERROR severity suggestion"
1341+ )
1342+
1343+
1344+ # 1b: cached result also returns valid=False
1345+ def test_load_and_validate_steps_less_yaml_not_cached_as_valid (tmp_path : Path ) -> None :
1346+ """After calling load_and_validate with steps-less YAML, the cache must not serve valid=True.""" # noqa: E501
1347+ import autoskillit .recipe ._api_cache as cache_mod
1348+
1349+ cache_mod ._LOAD_CACHE .clear ()
1350+ _setup_project_recipe (tmp_path , "test-no-steps" , _RECIPE_NO_STEPS )
1351+
1352+ from autoskillit .recipe ._api import load_and_validate
1353+
1354+ result1 = load_and_validate ("test-no-steps" , project_dir = tmp_path )
1355+ result2 = load_and_validate ("test-no-steps" , project_dir = tmp_path )
1356+
1357+ assert result1 ["valid" ] is False
1358+ assert result2 ["valid" ] is False , (
1359+ f"Cached result must also be valid=False; got valid={ result2 .get ('valid' )} "
1360+ )
1361+
1362+
1363+ # 1c: compute_recipe_validity is always the source of valid
1364+ def test_load_and_validate_always_invokes_compute_recipe_validity (
1365+ tmp_path : Path , monkeypatch : pytest .MonkeyPatch
1366+ ) -> None :
1367+ """Monkey-patch compute_recipe_validity with a sentinel and verify it flows through
1368+ for BOTH with-steps and without-steps recipes. This structurally guarantees that
1369+ no code path through load_and_validate can return a valid value that didn't come
1370+ from compute_recipe_validity.
1371+ """
1372+ import autoskillit .recipe ._api as api_mod
1373+ import autoskillit .recipe ._api_cache as cache_mod
1374+
1375+ cache_mod ._LOAD_CACHE .clear ()
1376+
1377+ _setup_project_recipe (tmp_path , "test-no-steps" , _RECIPE_NO_STEPS )
1378+ _setup_project_recipe (tmp_path , "test-recipe-with-rules" , _RECIPE_WITH_RULES )
1379+
1380+ sentinel_valid = object ()
1381+ monkeypatch .setattr (api_mod , "compute_recipe_validity" , lambda * a , ** kw : sentinel_valid )
1382+
1383+ from autoskillit .recipe ._api import load_and_validate
1384+
1385+ result_no_steps = load_and_validate ("test-no-steps" , project_dir = tmp_path )
1386+ assert result_no_steps ["valid" ] is sentinel_valid , (
1387+ f"Steps-less recipe must get valid from compute_recipe_validity sentinel; "
1388+ f"got valid={ result_no_steps .get ('valid' )!r} "
1389+ )
1390+
1391+ cache_mod ._LOAD_CACHE .clear ()
1392+ result_with_steps = load_and_validate ("test-recipe-with-rules" , project_dir = tmp_path )
1393+ assert result_with_steps ["valid" ] is sentinel_valid , (
1394+ f"With-steps recipe must get valid from compute_recipe_validity sentinel; "
1395+ f"got valid={ result_with_steps .get ('valid' )!r} "
1396+ )
1397+
1398+
1399+ # 1d: empty-steps recipe returns valid=False
1400+ def test_load_and_validate_empty_steps_returns_invalid (tmp_path : Path ) -> None :
1401+ """A YAML with steps: {} (present but empty) must produce valid=False."""
1402+ import autoskillit .recipe ._api_cache as cache_mod
1403+
1404+ cache_mod ._LOAD_CACHE .clear ()
1405+ _setup_project_recipe (tmp_path , "test-empty-steps" , _RECIPE_EMPTY_STEPS )
1406+
1407+ from autoskillit .recipe ._api import load_and_validate
1408+
1409+ result = load_and_validate ("test-empty-steps" , project_dir = tmp_path )
1410+
1411+ assert result ["valid" ] is False , (
1412+ f"Empty-steps YAML must return valid=False; got valid={ result .get ('valid' )} . "
1413+ f"Errors: { result .get ('errors' , [])} "
1414+ )
1415+ assert any ("step" in e .lower () for e in result ["errors" ]), (
1416+ f"Expected error about steps; got: { result ['errors' ]} "
1417+ )
1418+
1419+
1420+ # 1e: non-dict YAML returns valid=False
1421+ def test_load_and_validate_non_dict_root_returns_invalid (tmp_path : Path ) -> None :
1422+ """A YAML that parses to a list (non-dict root) must produce valid=False."""
1423+ import autoskillit .recipe ._api_cache as cache_mod
1424+
1425+ cache_mod ._LOAD_CACHE .clear ()
1426+ _setup_project_recipe (tmp_path , "test-list-root" , _RECIPE_NON_DICT_ROOT )
1427+
1428+ from autoskillit .recipe ._api import load_and_validate
1429+
1430+ result = load_and_validate ("test-list-root" , project_dir = tmp_path )
1431+
1432+ assert result ["valid" ] is False , (
1433+ f"Non-dict root YAML must return valid=False; got valid={ result .get ('valid' )} "
1434+ )
1435+
1436+
1437+ # 1f: non-dict steps value returns valid=False
1438+ def test_load_and_validate_non_dict_steps_value_returns_invalid (tmp_path : Path ) -> None :
1439+ """A YAML with steps: [foo, bar] (list instead of mapping) must produce valid=False
1440+ with a clear error, not an uncaught AttributeError.
1441+ """
1442+ import autoskillit .recipe ._api_cache as cache_mod
1443+
1444+ cache_mod ._LOAD_CACHE .clear ()
1445+ _setup_project_recipe (tmp_path , "test-list-steps" , _RECIPE_NON_DICT_STEPS )
1446+
1447+ from autoskillit .recipe ._api import load_and_validate
1448+
1449+ result = load_and_validate ("test-list-steps" , project_dir = tmp_path )
1450+
1451+ assert result ["valid" ] is False , (
1452+ f"Non-dict steps value must return valid=False; got valid={ result .get ('valid' )} . "
1453+ f"Suggestions: { result .get ('suggestions' , [])} "
1454+ )
1455+
1456+
1457+ # 1g: campaign recipe with dispatches and no steps validates correctly
1458+ def test_load_and_validate_campaign_no_steps_returns_valid (tmp_path : Path ) -> None :
1459+ """A campaign recipe (kind=campaign) with dispatches and no steps must return valid=True."""
1460+ import autoskillit .recipe ._api_cache as cache_mod
1461+
1462+ cache_mod ._LOAD_CACHE .clear ()
1463+ _setup_project_recipe (tmp_path , "test-campaign-no-steps" , _RECIPE_CAMPAIGN_NO_STEPS )
1464+
1465+ from autoskillit .recipe ._api import load_and_validate
1466+
1467+ result = load_and_validate ("test-campaign-no-steps" , project_dir = tmp_path )
1468+
1469+ assert result ["valid" ] is True , (
1470+ f"Campaign recipe with dispatches and no steps must be valid=True; "
1471+ f"got valid={ result .get ('valid' )} . Errors: { result .get ('errors' , [])} "
1472+ )
0 commit comments