From f67147d6e19b2fa26837c24e410f25d6a6069f43 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:29:41 -0500 Subject: [PATCH 01/21] fix(tools): preference exits with a message on a non-UTF-8 --groups file Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/tools.py | 2 +- dokimi/tests/test_tool_preference.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/commands/tools.py b/dokimi/dokimi/commands/tools.py index e0f19464f..ad4d99f0d 100644 --- a/dokimi/dokimi/commands/tools.py +++ b/dokimi/dokimi/commands/tools.py @@ -97,7 +97,7 @@ def cmd_tools_preference(args: argparse.Namespace) -> None: try: doc = tp.load_groups(json.loads(Path(args.groups).read_text(encoding="utf-8"))) - except (OSError, json.JSONDecodeError, tp.GroupsError) as exc: + except (OSError, ValueError, tp.GroupsError) as exc: # ValueError: not UTF-8 or not JSON raise SystemExit(f"--groups {args.groups}: {exc}") from exc factors = list(dict.fromkeys(f for f in args.factors.split(",") if f)) if args.factors else list(tp.FACTORS) unknown = sorted(set(factors) - set(tp.FACTORS)) diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index 2612bb6f5..b036d95ad 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -837,3 +837,14 @@ def test_dry_run_names_twin_a_when_only_twin_a_lacks_the_field(tmp_path, capsys) "--factors", "long_desc"]) args.func(args) assert "long_desc: skipped for 1 of 1 groups (G09 twin A)" in capsys.readouterr().out + + +def test_a_non_utf8_groups_file_exits_with_a_message(tmp_path): + from dokimi.cli import build_parser + + path = tmp_path / "groups.json" + path.write_bytes(bytes([0xFF, 0xFE, 0x00])) + args = build_parser().parse_args(["tools", "preference", "--groups", str(path), "--dry-run"]) + with pytest.raises(SystemExit) as exc: + args.func(args) + assert str(exc.value).startswith(f"--groups {path}: ") From 74000dcdf492ee03724c8a7263edd7157686a293 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:29:41 -0500 Subject: [PATCH 02/21] fix(tools): preference exits 1 with a message when --items-out cannot be written Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/tools.py | 11 ++++- .../tests/test_tools_preference_items_out.py | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 dokimi/tests/test_tools_preference_items_out.py diff --git a/dokimi/dokimi/commands/tools.py b/dokimi/dokimi/commands/tools.py index ad4d99f0d..cffad64d9 100644 --- a/dokimi/dokimi/commands/tools.py +++ b/dokimi/dokimi/commands/tools.py @@ -91,6 +91,15 @@ def _print_skipped(skipped: dict, n_groups: int) -> None: print(f" {factor}: skipped for {len(ids)} of {n_groups} groups ({', '.join(ids)})") +def _write_items(path: str, items: list) -> None: + """Write the probe items to ``path``, or exit 1 naming it when it cannot be written.""" + try: + Path(path).write_text(json.dumps(items, indent=1), encoding="utf-8") + except OSError as exc: + print(f"Error: cannot write --items-out {path}: {exc}", file=sys.stderr) + raise SystemExit(1) from None + + def cmd_tools_preference(args: argparse.Namespace) -> None: from dokimi.artifacts import save_artifact from dokimi import tool_preference as tp @@ -110,7 +119,7 @@ def cmd_tools_preference(args: argparse.Namespace) -> None: + (f", stopping at ${args.max_cost_usd:g}" if args.max_cost_usd else "")) _print_skipped(tp.skipped_factors(doc, factors), len(doc["groups"])) if args.items_out: - Path(args.items_out).write_text(json.dumps(items, indent=1), encoding="utf-8") + _write_items(args.items_out, items) print(f"items -> {args.items_out}") if args.dry_run: return diff --git a/dokimi/tests/test_tools_preference_items_out.py b/dokimi/tests/test_tools_preference_items_out.py new file mode 100644 index 000000000..68d02e7c1 --- /dev/null +++ b/dokimi/tests/test_tools_preference_items_out.py @@ -0,0 +1,45 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""`tools preference --items-out`: a path that cannot be written exits 1 with a message.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dokimi.cli import build_parser + +GROUPS = Path(__file__).resolve().parent / "fixtures" / "tool_preference" / "groups.json" + + +def _preference(items_out: Path) -> None: + args = build_parser().parse_args([ + "tools", "preference", "--groups", str(GROUPS), "--dry-run", "--items-out", str(items_out), + ]) + args.func(args) + + +def test_an_items_out_inside_a_regular_file_exits_1_naming_the_path(tmp_path, capsys): + blocker = tmp_path / "blocker" + blocker.write_text("x", encoding="utf-8") + target = blocker / "items.json" + with pytest.raises(SystemExit) as exc: + _preference(target) + assert exc.value.code == 1 + assert f"Error: cannot write --items-out {target}" in capsys.readouterr().err + + +def test_an_items_out_that_is_a_directory_exits_1_naming_the_path(tmp_path, capsys): + with pytest.raises(SystemExit) as exc: + _preference(tmp_path) + assert exc.value.code == 1 + assert f"Error: cannot write --items-out {tmp_path}" in capsys.readouterr().err + + +def test_a_writable_items_out_still_gets_the_items(tmp_path, capsys): + out = tmp_path / "items.json" + _preference(out) + assert out.read_text(encoding="utf-8").startswith("[") + assert f"items -> {out}" in capsys.readouterr().out From 891f9f77a1acc20abfafbd83266a9ba753ddae87 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:29:41 -0500 Subject: [PATCH 03/21] fix(catalog): trigger-set exits 1 with a message when --out cannot be written Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/catalog.py | 30 +++++++++++++++++++--------- dokimi/tests/test_skill_selection.py | 12 +++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/dokimi/dokimi/commands/catalog.py b/dokimi/dokimi/commands/catalog.py index 06ac62262..144521201 100644 --- a/dokimi/dokimi/commands/catalog.py +++ b/dokimi/dokimi/commands/catalog.py @@ -136,6 +136,26 @@ def cmd_catalog_scan(args: argparse.Namespace) -> None: raise SystemExit(1) +def _write_trigger_set(args: argparse.Namespace, queries, catalog_root: Path) -> None: + """Write the query set to ``--out`` in ``--format``, or exit 1 naming ``--out`` when it cannot be.""" + import json + + from dokimi.external import trigger_set as ts + + try: + if args.format == "questions": + from dokimi.questions.artifact import save_questions_artifact + + save_questions_artifact(ts.to_questions(queries), args.out) + else: + # External tools' formats: no schema of ours to hold them to. + payload = (ts.to_reach(queries, catalog_id=str(catalog_root)) if args.format == "reach" + else ts.to_skill_creator(queries, args.target)) + Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + except OSError as exc: # unwritable --out: a directory, a file as parent, no permission + raise SystemExit(f"Error: cannot write --out {args.out}: {exc}") from exc + + def cmd_catalog_trigger_set(args: argparse.Namespace) -> None: import json @@ -191,15 +211,7 @@ def rewrite(text: str) -> str: # cannot pick up a set with a mistyped label. print(json.dumps(summary, indent=2)) raise SystemExit(1) - if args.format == "questions": - from dokimi.questions.artifact import save_questions_artifact - - save_questions_artifact(ts.to_questions(queries), args.out) - else: - # External tools' formats: no schema of ours to hold them to. - payload = (ts.to_reach(queries, catalog_id=str(catalog_root)) if args.format == "reach" - else ts.to_skill_creator(queries, args.target)) - Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + _write_trigger_set(args, queries, catalog_root) print(json.dumps(summary, indent=2)) multi = sum(len(q.acceptable) > 1 for q in ts.labelled(queries)) diff --git a/dokimi/tests/test_skill_selection.py b/dokimi/tests/test_skill_selection.py index eceed7207..3dbce63a6 100644 --- a/dokimi/tests/test_skill_selection.py +++ b/dokimi/tests/test_skill_selection.py @@ -477,6 +477,18 @@ def test_trigger_set_cli_writes_each_format(tmp_path, catalog, capsys): assert "skill-reach credits only the first" in capsys.readouterr().out +@pytest.mark.parametrize("fmt", ["reach", "skill-creator", "questions"]) +def test_trigger_set_cli_exits_with_a_message_when_out_cannot_be_written(tmp_path, catalog, fmt): + q = _qfile(tmp_path, "q.json", [{"id": "1", "text": "profile it", "skills": ["linux-perf"]}]) + out = tmp_path / "taken" + out.mkdir() # a directory where the file should go + with pytest.raises(SystemExit, match=rf"Error: cannot write --out {out}: ") as exc: + _run_cli(["catalog", "trigger-set", "--catalog", str(catalog), "--out", str(out), + "--questions", f"{q}=@skills", "--format", fmt, "--target", "linux-perf"]) + assert exc.value.code != 0 and isinstance(exc.value.code, str) + assert list(out.iterdir()) == [] + + # ── review round 3 ─────────────────────────────────────────────────────── def _loop_result(answer="a", skill=None): return {"answer": answer, "transcript": [], "iterations": 1, "tool_call_count": 0, From c4df21c3c3bab9d54d108936d8fcffd676862a5c Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:29:41 -0500 Subject: [PATCH 04/21] fix(catalog): scan names the manifest file when skills.yaml or suites.json is not UTF-8 or unreadable Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/external/catalog.py | 8 +++-- dokimi/tests/test_catalog_command.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/external/catalog.py b/dokimi/dokimi/external/catalog.py index b52e88120..b91e609cb 100644 --- a/dokimi/dokimi/external/catalog.py +++ b/dokimi/dokimi/external/catalog.py @@ -141,7 +141,9 @@ def _manifest_items(manifest_path: Path) -> tuple[list, bool]: try: data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as exc: + except OSError as exc: + raise ValueError(f"cannot read skills.yaml at {manifest_path}: {exc}") from exc + except (yaml.YAMLError, UnicodeDecodeError) as exc: raise ValueError(f"invalid skills.yaml at {manifest_path}: {exc}") from exc if isinstance(data, dict): @@ -253,7 +255,9 @@ def read_task_manifest( try: data = json.loads(manifest_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: + except OSError as exc: + raise ValueError(f"cannot read suites.json at {manifest_path}: {exc}") from exc + except ValueError as exc: # JSONDecodeError, or bytes that are not UTF-8 raise ValueError(f"invalid suites.json at {manifest_path}: {exc}") from exc entries: list[TaskCatalogEntry] = [] diff --git a/dokimi/tests/test_catalog_command.py b/dokimi/tests/test_catalog_command.py index b86fa026e..1aa43058a 100644 --- a/dokimi/tests/test_catalog_command.py +++ b/dokimi/tests/test_catalog_command.py @@ -191,3 +191,49 @@ def test_catalog_scan_reports_clean_error_on_malformed_manifest(tmp_path): with pytest.raises(SystemExit) as exc_info: args.func(args) assert "invalid skills.yaml" in str(exc_info.value) + + +def test_catalog_scan_reports_clean_error_on_non_utf8_skills_manifest(tmp_path): + (tmp_path / "skills.yaml").write_bytes(b"skills: [\xff\xfe]\n") + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "skills"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert "invalid skills.yaml" in str(exc_info.value) + + +@pytest.mark.parametrize( + ("kind", "manifest", "content"), + [ + ("skills", "skills.yaml", "skills: []\n"), + ("tasks", "evaluation/harbor/suites.json", '{"suites": []}'), + ], +) +def test_catalog_scan_reports_clean_error_on_unreadable_manifest(tmp_path, monkeypatch, kind, manifest, content): + path = tmp_path / manifest + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + real_read_text = Path.read_text + + def read_text(self, *a, **kw): + if self == path: + raise PermissionError(13, "Permission denied", str(self)) + return real_read_text(self, *a, **kw) + + monkeypatch.setattr(Path, "read_text", read_text) + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", kind]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert f"cannot read {path.name} at {path}" in str(exc_info.value) + + +def test_catalog_scan_reports_clean_error_on_non_utf8_suites_manifest(tmp_path): + manifest = tmp_path / "evaluation" / "harbor" / "suites.json" + manifest.parent.mkdir(parents=True) + manifest.write_bytes(b'{"suites": ["\xff"]}') + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "tasks"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert "invalid suites.json" in str(exc_info.value) From 192057b04fea506feae402a11547efb04e89d973 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:37:29 -0500 Subject: [PATCH 05/21] chore: changelog fragment and growth baseline Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- .../20261025_120000_vchamarthi_cli-input-tools-catalog.md | 3 +++ dokimi/scripts/growth_baseline.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md diff --git a/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md b/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md new file mode 100644 index 000000000..be0be1205 --- /dev/null +++ b/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md @@ -0,0 +1,3 @@ +### Fixed + +- **`tools preference` and `catalog`** exit with a message on a non-UTF-8 `--groups` file, an unwritable `--out` or `--items-out`, or an unreadable manifest, instead of a traceback. diff --git a/dokimi/scripts/growth_baseline.json b/dokimi/scripts/growth_baseline.json index d0d35275e..569fed814 100644 --- a/dokimi/scripts/growth_baseline.json +++ b/dokimi/scripts/growth_baseline.json @@ -20,7 +20,7 @@ "dokimi/commands/arms.py::_validate_expected_skills": {"complexity": 11}, "dokimi/commands/benchmark.py::cmd_benchmark_batch": {"complexity": 11}, "dokimi/commands/benchmark.py::cmd_benchmark_preflight": {"complexity": 22}, - "dokimi/commands/catalog.py::cmd_catalog_trigger_set": {"complexity": 16}, + "dokimi/commands/catalog.py::cmd_catalog_trigger_set": {"complexity": 15}, "dokimi/commands/evaluate.py::cmd_eval_judge_validity": {"complexity": 29, "rationale": "grew on main in #73 (judge-validity --context stored) from 25 to 29 before the ratchet landed; split it in its own PR"}, "dokimi/commands/judge_reliability.py::cmd_eval_cross_judge": {"complexity": 32, "rationale": "pre-existing when the gate was introduced"}, "dokimi/commands/novelty.py::cmd_novelty_routing": {"complexity": 13, "rationale": "W8 adds the --repeats guard after main's no_queries handling raised the file; the command is split with the rest of novelty.py in a separate PR"}, From 766eae49c9a7124470dabe0c6c14fe0fbf49523d Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Mon, 26 Oct 2026 13:43:01 +0000 Subject: [PATCH 06/21] Finish the trigger-set writer docstring Signed-off-by: Anton Volkov --- dokimi/dokimi/commands/catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokimi/dokimi/commands/catalog.py b/dokimi/dokimi/commands/catalog.py index 144521201..1e8fc8a31 100644 --- a/dokimi/dokimi/commands/catalog.py +++ b/dokimi/dokimi/commands/catalog.py @@ -137,7 +137,7 @@ def cmd_catalog_scan(args: argparse.Namespace) -> None: def _write_trigger_set(args: argparse.Namespace, queries, catalog_root: Path) -> None: - """Write the query set to ``--out`` in ``--format``, or exit 1 naming ``--out`` when it cannot be.""" + """Write the query set to ``--out`` in ``--format``, or exit 1 naming ``--out`` when it cannot be written.""" import json from dokimi.external import trigger_set as ts From 89406271c45a04a147e3662e3aa67b6563924cb1 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Mon, 26 Oct 2026 13:43:03 +0000 Subject: [PATCH 07/21] Escape the --out path in the trigger-set test regex Signed-off-by: Anton Volkov --- dokimi/tests/test_skill_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokimi/tests/test_skill_selection.py b/dokimi/tests/test_skill_selection.py index 3dbce63a6..4c31db248 100644 --- a/dokimi/tests/test_skill_selection.py +++ b/dokimi/tests/test_skill_selection.py @@ -482,7 +482,7 @@ def test_trigger_set_cli_exits_with_a_message_when_out_cannot_be_written(tmp_pat q = _qfile(tmp_path, "q.json", [{"id": "1", "text": "profile it", "skills": ["linux-perf"]}]) out = tmp_path / "taken" out.mkdir() # a directory where the file should go - with pytest.raises(SystemExit, match=rf"Error: cannot write --out {out}: ") as exc: + with pytest.raises(SystemExit, match=rf"Error: cannot write --out {re.escape(str(out))}: ") as exc: _run_cli(["catalog", "trigger-set", "--catalog", str(catalog), "--out", str(out), "--questions", f"{q}=@skills", "--format", fmt, "--target", "linux-perf"]) assert exc.value.code != 0 and isinstance(exc.value.code, str) From 0f9bfd9e68a6abce356c1f7c788e8b4cf4c16974 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:02:14 -0500 Subject: [PATCH 08/21] fix(cli): reject wrong-shaped suites.json and groups files, guard --out-json and empty --out read_task_manifest raises ValueError naming suites.json when the top level, suites or a suite's tasks have the wrong type. tools preference --groups rejects a non-object document and catches only JSON, UTF-8, OS and groups errors. --out-json and an empty trigger-set --out exit 1 with a message instead of a traceback. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- ...0000_vchamarthi_cli-input-tools-catalog.md | 2 +- dokimi/dokimi/commands/catalog.py | 2 +- dokimi/dokimi/commands/tools.py | 16 +- dokimi/dokimi/external/catalog.py | 145 ++++++++++-------- dokimi/dokimi/tool_preference.py | 2 + dokimi/tests/test_catalog_command.py | 13 ++ dokimi/tests/test_skill_selection.py | 9 ++ dokimi/tests/test_tool_preference.py | 25 ++- 8 files changed, 143 insertions(+), 71 deletions(-) diff --git a/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md b/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md index be0be1205..346c77280 100644 --- a/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md +++ b/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md @@ -1,3 +1,3 @@ ### Fixed -- **`tools preference` and `catalog`** exit with a message on a non-UTF-8 `--groups` file, an unwritable `--out` or `--items-out`, or an unreadable manifest, instead of a traceback. +- **`tools preference` and `catalog`** exit with a message on a non-UTF-8 `--groups` file, an unwritable `--out`, `--items-out` or `--out-json`, or an unreadable or wrong-shaped manifest, instead of a traceback. diff --git a/dokimi/dokimi/commands/catalog.py b/dokimi/dokimi/commands/catalog.py index 1e8fc8a31..7a6fe74f4 100644 --- a/dokimi/dokimi/commands/catalog.py +++ b/dokimi/dokimi/commands/catalog.py @@ -152,7 +152,7 @@ def _write_trigger_set(args: argparse.Namespace, queries, catalog_root: Path) -> payload = (ts.to_reach(queries, catalog_id=str(catalog_root)) if args.format == "reach" else ts.to_skill_creator(queries, args.target)) Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - except OSError as exc: # unwritable --out: a directory, a file as parent, no permission + except (OSError, ValueError) as exc: # unwritable --out: a directory, a file as parent, no permission, empty path raise SystemExit(f"Error: cannot write --out {args.out}: {exc}") from exc diff --git a/dokimi/dokimi/commands/tools.py b/dokimi/dokimi/commands/tools.py index cffad64d9..d1c2b238d 100644 --- a/dokimi/dokimi/commands/tools.py +++ b/dokimi/dokimi/commands/tools.py @@ -100,13 +100,23 @@ def _write_items(path: str, items: list) -> None: raise SystemExit(1) from None -def cmd_tools_preference(args: argparse.Namespace) -> None: +def _save_result(data: dict, path: str) -> None: + """Save the tool_preference artifact to ``path``, or exit 1 naming it when it cannot be written.""" from dokimi.artifacts import save_artifact + + try: + save_artifact("tool_preference", data, path) + except OSError as exc: + print(f"Error: cannot write {path}: {exc}", file=sys.stderr) + raise SystemExit(1) from None + + +def cmd_tools_preference(args: argparse.Namespace) -> None: from dokimi import tool_preference as tp try: doc = tp.load_groups(json.loads(Path(args.groups).read_text(encoding="utf-8"))) - except (OSError, ValueError, tp.GroupsError) as exc: # ValueError: not UTF-8 or not JSON + except (OSError, json.JSONDecodeError, UnicodeDecodeError, tp.GroupsError) as exc: raise SystemExit(f"--groups {args.groups}: {exc}") from exc factors = list(dict.fromkeys(f for f in args.factors.split(",") if f)) if args.factors else list(tp.FACTORS) unknown = sorted(set(factors) - set(tp.FACTORS)) @@ -136,7 +146,7 @@ def progress(done: int, total: int) -> None: data = tp.payload(doc, items, results, model=args.model, provider=args.provider, reps=args.reps, factors=factors, max_tokens=args.max_tokens, max_cost_usd=args.max_cost_usd) - save_artifact("tool_preference", data, args.out_json) + _save_result(data, args.out_json) _print_summary(data["summary"]) u = data["usage"] print(f"cost: {u['cost_usd']} USD over {u['n_calls']} calls ({u['cost_known_calls']} priced)") diff --git a/dokimi/dokimi/external/catalog.py b/dokimi/dokimi/external/catalog.py index b91e609cb..077774e4d 100644 --- a/dokimi/dokimi/external/catalog.py +++ b/dokimi/dokimi/external/catalog.py @@ -230,6 +230,26 @@ def failed(error: str, path: Optional[Path] = None) -> SkillCatalogEntry: source_root=root, skill=skill) +def _manifest_tasks(data: object, manifest_path: Path) -> list[dict]: + """Return the task objects of every suite in a parsed suites.json, or raise ValueError naming the file.""" + + def invalid(why: str) -> ValueError: + return ValueError(f"invalid suites.json at {manifest_path}: {why}") + + if not isinstance(data, dict): + raise invalid(f"top level must be an object, got {type(data).__name__}") + suites = data.get("suites") or [] + if not isinstance(suites, list) or not all(isinstance(s, dict) for s in suites): + raise invalid("suites must be a list of objects") + tasks: list[dict] = [] + for suite in suites: + suite_tasks = suite.get("tasks") or [] + if not isinstance(suite_tasks, list) or not all(isinstance(t, dict) for t in suite_tasks): + raise invalid("each suite's tasks must be a list of objects") + tasks.extend(suite_tasks) + return tasks + + def read_task_manifest( root: Path, tasks_subdir: str = DEFAULT_TASKS_SUBDIR, @@ -261,79 +281,78 @@ def read_task_manifest( raise ValueError(f"invalid suites.json at {manifest_path}: {exc}") from exc entries: list[TaskCatalogEntry] = [] - for suite in data.get("suites") or []: - for item in suite.get("tasks") or []: - name = str(item.get("name") or "").strip() - status = item.get("status") - track = item.get("track") - task_dir = tasks_root / name - - if not _within(task_dir, tasks_root): - # A failure, not a skip: the manifest asked us to leave the - # checkout, which is the same thing ``read_skill_manifest`` - # reports as an error for ``catalog_path``. - entries.append( - TaskCatalogEntry( - name=name, - status=status, - track=track, - path=None, - failed=True, - reason=f"task name escapes tasks root: {name!r}", - ) - ) - continue - - if not is_task_dir(task_dir): - entries.append( - TaskCatalogEntry( - name=name, - status=status, - track=track, - path=None, - skipped=True, - reason=f"no task.toml or task.md under {task_dir} (manifest status={status!r})", - ) + for item in _manifest_tasks(data, manifest_path): + name = str(item.get("name") or "").strip() + status = item.get("status") + track = item.get("track") + task_dir = tasks_root / name + + if not _within(task_dir, tasks_root): + # A failure, not a skip: the manifest asked us to leave the + # checkout, which is the same thing ``read_skill_manifest`` + # reports as an error for ``catalog_path``. + entries.append( + TaskCatalogEntry( + name=name, + status=status, + track=track, + path=None, + failed=True, + reason=f"task name escapes tasks root: {name!r}", ) - continue - - if track != "executable": - entries.append( - TaskCatalogEntry( - name=name, - status=status, - track=track, - path=task_dir, - skipped=True, - reason=f"track={track!r} is not an executable Harbor task", - ) + ) + continue + + if not is_task_dir(task_dir): + entries.append( + TaskCatalogEntry( + name=name, + status=status, + track=track, + path=None, + skipped=True, + reason=f"no task.toml or task.md under {task_dir} (manifest status={status!r})", ) - continue - - try: - task = load_task(task_dir) - except (FileNotFoundError, ValueError) as exc: - # The entry is declared executable and its directory is on - # disk, so this is the case the scan exists to catch. - entries.append( - TaskCatalogEntry( - name=name, - status=status, - track=track, - path=task_dir, - failed=True, - reason=f"failed to load: {exc}", - ) + ) + continue + + if track != "executable": + entries.append( + TaskCatalogEntry( + name=name, + status=status, + track=track, + path=task_dir, + skipped=True, + reason=f"track={track!r} is not an executable Harbor task", ) - continue + ) + continue + try: + task = load_task(task_dir) + except (FileNotFoundError, ValueError) as exc: + # The entry is declared executable and its directory is on + # disk, so this is the case the scan exists to catch. entries.append( TaskCatalogEntry( name=name, status=status, track=track, path=task_dir, - task=task, + failed=True, + reason=f"failed to load: {exc}", ) ) + continue + + entries.append( + TaskCatalogEntry( + name=name, + status=status, + track=track, + path=task_dir, + task=task, + ) + ) return entries diff --git a/dokimi/dokimi/tool_preference.py b/dokimi/dokimi/tool_preference.py index d58430a80..24f5ab358 100644 --- a/dokimi/dokimi/tool_preference.py +++ b/dokimi/dokimi/tool_preference.py @@ -56,6 +56,8 @@ class GroupsError(ValueError): def load_groups(doc: dict) -> dict: """Validate a tool_preference_groups.v1 document and return it.""" + if not isinstance(doc, dict): + raise GroupsError(f"top level must be an object, got {type(doc).__name__}") problems = schema_errors(GROUPS_SCHEMA, doc) ids = [g["id"] for g in doc.get("groups", []) if isinstance(g, dict) and isinstance(g.get("id"), str)] dupes = sorted({i for i in ids if ids.count(i) > 1}) diff --git a/dokimi/tests/test_catalog_command.py b/dokimi/tests/test_catalog_command.py index 1aa43058a..f316183a8 100644 --- a/dokimi/tests/test_catalog_command.py +++ b/dokimi/tests/test_catalog_command.py @@ -237,3 +237,16 @@ def test_catalog_scan_reports_clean_error_on_non_utf8_suites_manifest(tmp_path): with pytest.raises(SystemExit) as exc_info: args.func(args) assert "invalid suites.json" in str(exc_info.value) + + +@pytest.mark.parametrize("text", ["[]", "null", '{"suites": 5}', '{"suites": [1]}', '{"suites": [{"tasks": [3]}]}', + '{"suites": [{"tasks": 3}]}']) +def test_catalog_scan_reports_clean_error_on_wrong_shaped_suites_manifest(tmp_path, text): + manifest = tmp_path / "evaluation" / "harbor" / "suites.json" + manifest.parent.mkdir(parents=True) + manifest.write_text(text, encoding="utf-8") + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "tasks"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert f"invalid suites.json at {manifest}" in str(exc_info.value) diff --git a/dokimi/tests/test_skill_selection.py b/dokimi/tests/test_skill_selection.py index 4c31db248..05479ea20 100644 --- a/dokimi/tests/test_skill_selection.py +++ b/dokimi/tests/test_skill_selection.py @@ -489,6 +489,15 @@ def test_trigger_set_cli_exits_with_a_message_when_out_cannot_be_written(tmp_pat assert list(out.iterdir()) == [] +@pytest.mark.parametrize("fmt", ["reach", "skill-creator", "questions"]) +def test_trigger_set_cli_exits_with_a_message_when_out_is_empty(tmp_path, catalog, fmt): + q = _qfile(tmp_path, "q.json", [{"id": "1", "text": "profile it", "skills": ["linux-perf"]}]) + with pytest.raises(SystemExit, match=r"Error: cannot write --out : ") as exc: + _run_cli(["catalog", "trigger-set", "--catalog", str(catalog), "--out", "", + "--questions", f"{q}=@skills", "--format", fmt, "--target", "linux-perf"]) + assert isinstance(exc.value.code, str) + + # ── review round 3 ─────────────────────────────────────────────────────── def _loop_result(answer="a", skill=None): return {"answer": answer, "transcript": [], "iterations": 1, "tool_call_count": 0, diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index b036d95ad..78e715477 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -839,12 +839,31 @@ def test_dry_run_names_twin_a_when_only_twin_a_lacks_the_field(tmp_path, capsys) assert "long_desc: skipped for 1 of 1 groups (G09 twin A)" in capsys.readouterr().out -def test_a_non_utf8_groups_file_exits_with_a_message(tmp_path): +@pytest.mark.parametrize("content", [bytes([0xFF, 0xFE, 0x00]), b"{not json", b"[]", b"null", None]) +def test_an_unreadable_or_invalid_groups_file_exits_with_a_message(tmp_path, content): from dokimi.cli import build_parser - path = tmp_path / "groups.json" - path.write_bytes(bytes([0xFF, 0xFE, 0x00])) + path = tmp_path / "groups.json" # content None: the path does not exist + if content is not None: + path.write_bytes(content) args = build_parser().parse_args(["tools", "preference", "--groups", str(path), "--dry-run"]) with pytest.raises(SystemExit) as exc: args.func(args) assert str(exc.value).startswith(f"--groups {path}: ") + + +def test_an_unwritable_out_json_exits_with_a_message(monkeypatch, tmp_path, capsys): + import dokimi.llm as llm + from dokimi.cli import build_parser + + monkeypatch.setattr(llm, "chat_completion", + lambda messages, model, provider="openai", tools=None, **kw: _Resp([tools[-1]["function"]["name"]])) + blocker = tmp_path / "blocker" + blocker.write_text("x", encoding="utf-8") # a regular file where a directory is needed + out = blocker / "o.json" + args = build_parser().parse_args(["tools", "preference", "--groups", str(GROUPS), "--model", "m", + "--reps", "1", "--factors", "assertive", "--out-json", str(out)]) + with pytest.raises(SystemExit) as exc: + args.func(args) + assert exc.value.code == 1 + assert f"Error: cannot write {out}: " in capsys.readouterr().err From 5d1c627fe3d8fdd6a1bbef626774210373373bb0 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:10:54 -0500 Subject: [PATCH 09/21] fix(cli): report a rejected questions set separately from an unwritable --out ArtifactValidationError subclasses ValueError, so a schema failure was reported as 'cannot write --out'. It now exits with 'questions set rejected', and the write-failure message stays for path errors. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/catalog.py | 5 ++++- dokimi/tests/test_skill_selection.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/commands/catalog.py b/dokimi/dokimi/commands/catalog.py index 7a6fe74f4..9e218ce1e 100644 --- a/dokimi/dokimi/commands/catalog.py +++ b/dokimi/dokimi/commands/catalog.py @@ -140,6 +140,7 @@ def _write_trigger_set(args: argparse.Namespace, queries, catalog_root: Path) -> """Write the query set to ``--out`` in ``--format``, or exit 1 naming ``--out`` when it cannot be written.""" import json + from dokimi.artifacts import ArtifactValidationError from dokimi.external import trigger_set as ts try: @@ -152,7 +153,9 @@ def _write_trigger_set(args: argparse.Namespace, queries, catalog_root: Path) -> payload = (ts.to_reach(queries, catalog_id=str(catalog_root)) if args.format == "reach" else ts.to_skill_creator(queries, args.target)) Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - except (OSError, ValueError) as exc: # unwritable --out: a directory, a file as parent, no permission, empty path + except ArtifactValidationError as exc: # the path was writable; the payload failed the schema + raise SystemExit(f"Error: questions set rejected: {exc}") from exc + except (OSError, ValueError) as exc: # unwritable --out: a directory, a file as parent, no permission, empty path (pathlib ValueError) raise SystemExit(f"Error: cannot write --out {args.out}: {exc}") from exc diff --git a/dokimi/tests/test_skill_selection.py b/dokimi/tests/test_skill_selection.py index 05479ea20..f720d9d2c 100644 --- a/dokimi/tests/test_skill_selection.py +++ b/dokimi/tests/test_skill_selection.py @@ -489,6 +489,21 @@ def test_trigger_set_cli_exits_with_a_message_when_out_cannot_be_written(tmp_pat assert list(out.iterdir()) == [] +def test_trigger_set_cli_reports_a_schema_rejection_not_a_write_failure(tmp_path, catalog, monkeypatch): + from dokimi.artifacts import ArtifactValidationError + from dokimi.questions import artifact + + def reject(*_a, **_k): + raise ArtifactValidationError("record 1: bad shape") + + monkeypatch.setattr(artifact, "save_questions_artifact", reject) + q = _qfile(tmp_path, "q.json", [{"id": "1", "text": "profile it", "skills": ["linux-perf"]}]) + with pytest.raises(SystemExit, match=r"Error: questions set rejected: record 1: bad shape") as exc: + _run_cli(["catalog", "trigger-set", "--catalog", str(catalog), "--out", str(tmp_path / "o.json"), + "--questions", f"{q}=@skills", "--format", "questions"]) + assert "cannot write" not in exc.value.code + + @pytest.mark.parametrize("fmt", ["reach", "skill-creator", "questions"]) def test_trigger_set_cli_exits_with_a_message_when_out_is_empty(tmp_path, catalog, fmt): q = _qfile(tmp_path, "q.json", [{"id": "1", "text": "profile it", "skills": ["linux-perf"]}]) From 53e5e33b29ec9a8ca5ab16e8ec4308ab9a383302 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:57:28 -0500 Subject: [PATCH 10/21] fix(cli): exit with a message for a null groups list, deep or huge JSON, and an empty --out-json load_groups iterates groups only when it is a list, so a null value is reported by the schema check. The groups file read also catches RecursionError and ValueError. _save_result catches ValueError next to OSError and reports a schema rejection as a rejected artifact. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/tools.py | 9 ++++++--- dokimi/dokimi/tool_preference.py | 6 ++++-- dokimi/tests/test_tool_preference.py | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/dokimi/dokimi/commands/tools.py b/dokimi/dokimi/commands/tools.py index d1c2b238d..3724d0e37 100644 --- a/dokimi/dokimi/commands/tools.py +++ b/dokimi/dokimi/commands/tools.py @@ -102,11 +102,14 @@ def _write_items(path: str, items: list) -> None: def _save_result(data: dict, path: str) -> None: """Save the tool_preference artifact to ``path``, or exit 1 naming it when it cannot be written.""" - from dokimi.artifacts import save_artifact + from dokimi.artifacts import ArtifactValidationError, save_artifact try: save_artifact("tool_preference", data, path) - except OSError as exc: + except ArtifactValidationError as exc: # the path was writable; the payload failed the schema + print(f"Error: tool_preference artifact rejected: {exc}", file=sys.stderr) + raise SystemExit(1) from None + except (OSError, ValueError) as exc: # unwritable path, including an empty one (pathlib ValueError) print(f"Error: cannot write {path}: {exc}", file=sys.stderr) raise SystemExit(1) from None @@ -116,7 +119,7 @@ def cmd_tools_preference(args: argparse.Namespace) -> None: try: doc = tp.load_groups(json.loads(Path(args.groups).read_text(encoding="utf-8"))) - except (OSError, json.JSONDecodeError, UnicodeDecodeError, tp.GroupsError) as exc: + except (OSError, RecursionError, ValueError, tp.GroupsError) as exc: raise SystemExit(f"--groups {args.groups}: {exc}") from exc factors = list(dict.fromkeys(f for f in args.factors.split(",") if f)) if args.factors else list(tp.FACTORS) unknown = sorted(set(factors) - set(tp.FACTORS)) diff --git a/dokimi/dokimi/tool_preference.py b/dokimi/dokimi/tool_preference.py index 24f5ab358..c152dfc6c 100644 --- a/dokimi/dokimi/tool_preference.py +++ b/dokimi/dokimi/tool_preference.py @@ -59,11 +59,13 @@ def load_groups(doc: dict) -> dict: if not isinstance(doc, dict): raise GroupsError(f"top level must be an object, got {type(doc).__name__}") problems = schema_errors(GROUPS_SCHEMA, doc) - ids = [g["id"] for g in doc.get("groups", []) if isinstance(g, dict) and isinstance(g.get("id"), str)] + raw = doc.get("groups") + groups = raw if isinstance(raw, list) else [] + ids = [g["id"] for g in groups if isinstance(g, dict) and isinstance(g.get("id"), str)] dupes = sorted({i for i in ids if ids.count(i) > 1}) if dupes: problems.append(f"groups: duplicate ids {dupes}") - for g in doc.get("groups", []) if not problems else []: + for g in groups if not problems else []: names = [g["twins"]["A"]["name"], g["twins"]["B"]["name"]] + [d["name"] for d in g["distractors"]] names += [t["vendor_name"] for t in g["twins"].values() if t.get("vendor_name")] if len(set(names)) != len(names): diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index 78e715477..4a77aeaed 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -839,7 +839,9 @@ def test_dry_run_names_twin_a_when_only_twin_a_lacks_the_field(tmp_path, capsys) assert "long_desc: skipped for 1 of 1 groups (G09 twin A)" in capsys.readouterr().out -@pytest.mark.parametrize("content", [bytes([0xFF, 0xFE, 0x00]), b"{not json", b"[]", b"null", None]) +@pytest.mark.parametrize("content", [bytes([0xFF, 0xFE, 0x00]), b"{not json", b"[]", b"null", None, + b'{"schema_version": "tool_preference_groups.v1", "groups": null}', + pytest.param(b"[" * 200000, id="deep-nesting"), pytest.param(b"1" * 6000, id="huge-int")]) def test_an_unreadable_or_invalid_groups_file_exits_with_a_message(tmp_path, content): from dokimi.cli import build_parser @@ -867,3 +869,17 @@ def test_an_unwritable_out_json_exits_with_a_message(monkeypatch, tmp_path, caps args.func(args) assert exc.value.code == 1 assert f"Error: cannot write {out}: " in capsys.readouterr().err + + +def test_an_empty_out_json_exits_with_a_message(monkeypatch, capsys): + import dokimi.llm as llm + from dokimi.cli import build_parser + + monkeypatch.setattr(llm, "chat_completion", + lambda messages, model, provider="openai", tools=None, **kw: _Resp([tools[-1]["function"]["name"]])) + args = build_parser().parse_args(["tools", "preference", "--groups", str(GROUPS), "--model", "m", + "--reps", "1", "--factors", "assertive", "--out-json", ""]) + with pytest.raises(SystemExit) as exc: + args.func(args) + assert exc.value.code == 1 + assert "Error: cannot write : " in capsys.readouterr().err From 6c4295c0152dc51ef17de200bbfdd9ee486389ce Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:27:08 -0500 Subject: [PATCH 11/21] fix(catalog): report deeply nested manifests and unusable task names with a message A deeply nested skills.yaml or suites.json raised RecursionError and a task name longer than the filesystem allows raised OSError from is_task_dir. Both now produce the invalid-manifest error or a failed catalog entry. Also cover the tool_preference artifact rejected branch with a test. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/external/catalog.py | 21 ++++++++++++++++++--- dokimi/tests/test_catalog_command.py | 28 +++++++++++++++++++++++++++- dokimi/tests/test_tool_preference.py | 21 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/dokimi/dokimi/external/catalog.py b/dokimi/dokimi/external/catalog.py index 077774e4d..0182661c2 100644 --- a/dokimi/dokimi/external/catalog.py +++ b/dokimi/dokimi/external/catalog.py @@ -143,7 +143,7 @@ def _manifest_items(manifest_path: Path) -> tuple[list, bool]: data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} except OSError as exc: raise ValueError(f"cannot read skills.yaml at {manifest_path}: {exc}") from exc - except (yaml.YAMLError, UnicodeDecodeError) as exc: + except (yaml.YAMLError, UnicodeDecodeError, RecursionError) as exc: raise ValueError(f"invalid skills.yaml at {manifest_path}: {exc}") from exc if isinstance(data, dict): @@ -277,7 +277,7 @@ def read_task_manifest( data = json.loads(manifest_path.read_text(encoding="utf-8")) except OSError as exc: raise ValueError(f"cannot read suites.json at {manifest_path}: {exc}") from exc - except ValueError as exc: # JSONDecodeError, or bytes that are not UTF-8 + except (ValueError, RecursionError) as exc: # JSONDecodeError, non-UTF-8 bytes, or nesting too deep raise ValueError(f"invalid suites.json at {manifest_path}: {exc}") from exc entries: list[TaskCatalogEntry] = [] @@ -303,7 +303,22 @@ def read_task_manifest( ) continue - if not is_task_dir(task_dir): + try: + task_dir_found = is_task_dir(task_dir) + except OSError as exc: # e.g. a task name longer than the filesystem allows + entries.append( + TaskCatalogEntry( + name=name, + status=status, + track=track, + path=None, + failed=True, + reason=f"cannot inspect task directory {task_dir}: {exc}", + ) + ) + continue + + if not task_dir_found: entries.append( TaskCatalogEntry( name=name, diff --git a/dokimi/tests/test_catalog_command.py b/dokimi/tests/test_catalog_command.py index f316183a8..e9b69c37b 100644 --- a/dokimi/tests/test_catalog_command.py +++ b/dokimi/tests/test_catalog_command.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -240,7 +241,7 @@ def test_catalog_scan_reports_clean_error_on_non_utf8_suites_manifest(tmp_path): @pytest.mark.parametrize("text", ["[]", "null", '{"suites": 5}', '{"suites": [1]}', '{"suites": [{"tasks": [3]}]}', - '{"suites": [{"tasks": 3}]}']) + '{"suites": [{"tasks": 3}]}', pytest.param("[" * 200000, id="deeply-nested")]) def test_catalog_scan_reports_clean_error_on_wrong_shaped_suites_manifest(tmp_path, text): manifest = tmp_path / "evaluation" / "harbor" / "suites.json" manifest.parent.mkdir(parents=True) @@ -250,3 +251,28 @@ def test_catalog_scan_reports_clean_error_on_wrong_shaped_suites_manifest(tmp_pa with pytest.raises(SystemExit) as exc_info: args.func(args) assert f"invalid suites.json at {manifest}" in str(exc_info.value) + + +def test_catalog_scan_reports_clean_error_on_deeply_nested_skills_manifest(tmp_path): + (tmp_path / "skills.yaml").write_text("[" * 200000, encoding="utf-8") + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "skills"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert f"invalid skills.yaml at {tmp_path / 'skills.yaml'}" in str(exc_info.value) + + +def test_catalog_scan_reports_a_task_name_the_filesystem_rejects(tmp_path, monkeypatch): + from dokimi.external import catalog as cat + + manifest = tmp_path / "evaluation" / "harbor" / "suites.json" + manifest.parent.mkdir(parents=True) + name = "a" * 300 + manifest.write_text(json.dumps({"suites": [{"tasks": [{"name": name, "track": "executable"}]}]}), encoding="utf-8") + + def too_long(path): + raise OSError(36, "File name too long", str(path)) + + monkeypatch.setattr(cat, "is_task_dir", too_long) + entries = cat.read_task_manifest(tmp_path) + assert entries[0].failed and "File name too long" in entries[0].reason diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index 4a77aeaed..bfc04bf3d 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -883,3 +883,24 @@ def test_an_empty_out_json_exits_with_a_message(monkeypatch, capsys): args.func(args) assert exc.value.code == 1 assert "Error: cannot write : " in capsys.readouterr().err + + +def test_a_schema_rejected_artifact_is_not_reported_as_a_write_failure(monkeypatch, capsys, tmp_path): + import dokimi.artifacts as artifacts + import dokimi.llm as llm + from dokimi.cli import build_parser + + def reject(*a, **kw): + raise artifacts.ArtifactValidationError("reps must be >= 1") + + monkeypatch.setattr(llm, "chat_completion", + lambda messages, model, provider="openai", tools=None, **kw: _Resp([tools[-1]["function"]["name"]])) + monkeypatch.setattr(artifacts, "save_artifact", reject) + args = build_parser().parse_args(["tools", "preference", "--groups", str(GROUPS), "--model", "m", + "--reps", "1", "--factors", "assertive", "--out-json", str(tmp_path / "o.json")]) + with pytest.raises(SystemExit) as exc: + args.func(args) + err = capsys.readouterr().err + assert exc.value.code == 1 + assert "tool_preference artifact rejected" in err + assert "cannot write" not in err From 61cdeb2e08082eb36c7287b0c8c2936596ec8d79 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:06:08 -0500 Subject: [PATCH 12/21] fix(catalog): exit with a message on a malformed SKILL.md or a task directory without instruction.md catalog trigger-set now reports an unreadable or malformed SKILL.md under --catalog, and catalog scan --kind tasks without suites.json reports a task directory missing instruction.md, instead of a traceback. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- ...0000_vchamarthi_cli-input-tools-catalog.md | 2 +- dokimi/dokimi/commands/catalog.py | 12 +++++++-- dokimi/dokimi/external/catalog.py | 10 ++++++- dokimi/tests/test_catalog_command.py | 26 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md b/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md index 346c77280..391afc34f 100644 --- a/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md +++ b/dokimi/changelog.d/20261025_120000_vchamarthi_cli-input-tools-catalog.md @@ -1,3 +1,3 @@ ### Fixed -- **`tools preference` and `catalog`** exit with a message on a non-UTF-8 `--groups` file, an unwritable `--out`, `--items-out` or `--out-json`, or an unreadable or wrong-shaped manifest, instead of a traceback. +- **`tools preference` and `catalog`** exit with a message on a non-UTF-8 `--groups` file, an unwritable `--out`, `--items-out` or `--out-json`, an unreadable or wrong-shaped manifest, a malformed `SKILL.md` under `--catalog`, or a task directory without `instruction.md`, instead of a traceback. diff --git a/dokimi/dokimi/commands/catalog.py b/dokimi/dokimi/commands/catalog.py index 9e218ce1e..acfc10ef9 100644 --- a/dokimi/dokimi/commands/catalog.py +++ b/dokimi/dokimi/commands/catalog.py @@ -159,16 +159,24 @@ def _write_trigger_set(args: argparse.Namespace, queries, catalog_root: Path) -> raise SystemExit(f"Error: cannot write --out {args.out}: {exc}") from exc +def _discover_catalog_skills(catalog_root: Path) -> list: + from dokimi.skills import discover_skills + + try: + return discover_skills(catalog_root) + except (OSError, ValueError, RecursionError) as exc: # unreadable, non-UTF-8, bad frontmatter or nesting too deep + raise SystemExit(f"--catalog {catalog_root}: {exc}") from exc + + def cmd_catalog_trigger_set(args: argparse.Namespace) -> None: import json from dokimi.external import trigger_set as ts - from dokimi.skills import discover_skills catalog_root = Path(args.catalog) if not catalog_root.is_dir(): raise SystemExit(f"--catalog is not a directory: {catalog_root}") - catalog = discover_skills(catalog_root) + catalog = _discover_catalog_skills(catalog_root) # Checked before any --rewrite call, so a bad invocation costs no LLM calls. if args.format == "skill-creator": if not args.target: diff --git a/dokimi/dokimi/external/catalog.py b/dokimi/dokimi/external/catalog.py index 0182661c2..15cbb618d 100644 --- a/dokimi/dokimi/external/catalog.py +++ b/dokimi/dokimi/external/catalog.py @@ -250,6 +250,13 @@ def invalid(why: str) -> ValueError: return tasks +def _discover_tasks_checked(tasks_root: Path) -> list: + try: + return discover_tasks(tasks_root) + except (OSError, ValueError) as exc: # e.g. a task directory without instruction.md + raise ValueError(f"cannot read tasks at {tasks_root}: {exc}") from exc + + def read_task_manifest( root: Path, tasks_subdir: str = DEFAULT_TASKS_SUBDIR, @@ -262,6 +269,7 @@ def read_task_manifest( manifest_path = root / DEFAULT_SUITES_MANIFEST tasks_root = root / tasks_subdir if not manifest_path.is_file(): + found = _discover_tasks_checked(tasks_root) return [ TaskCatalogEntry( name=task.name, @@ -270,7 +278,7 @@ def read_task_manifest( path=task.path, task=task, ) - for task in discover_tasks(tasks_root) + for task in found ] try: diff --git a/dokimi/tests/test_catalog_command.py b/dokimi/tests/test_catalog_command.py index e9b69c37b..5edbf61b0 100644 --- a/dokimi/tests/test_catalog_command.py +++ b/dokimi/tests/test_catalog_command.py @@ -276,3 +276,29 @@ def too_long(path): monkeypatch.setattr(cat, "is_task_dir", too_long) entries = cat.read_task_manifest(tmp_path) assert entries[0].failed and "File name too long" in entries[0].reason + + +@pytest.mark.parametrize("content", [b"\xff\xfe", b"---\nname: a\n---\nbody\n"], ids=["non-utf8", "missing-description"]) +def test_catalog_trigger_set_reports_clean_error_on_malformed_skill_md(tmp_path, content): + catalog = tmp_path / "skills" + (catalog / "a").mkdir(parents=True) + (catalog / "a" / "SKILL.md").write_bytes(content) + questions = tmp_path / "q.json" + questions.write_text(json.dumps([{"id": "q1", "question": "hi"}]), encoding="utf-8") + + args = build_parser().parse_args(["catalog", "trigger-set", "--catalog", str(catalog), + "--questions", f"{questions}=none", "--out", str(tmp_path / "o.json")]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert f"--catalog {catalog}:" in str(exc_info.value) + + +def test_catalog_scan_reports_clean_error_on_task_dir_without_instruction(tmp_path): + task = tmp_path / "evaluation" / "harbor" / "tasks" / "t" + task.mkdir(parents=True) + (task / "task.toml").write_text("", encoding="utf-8") + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "tasks"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert "cannot read tasks at" in str(exc_info.value) From 23cdfe495b250b37fc930c7fb25daf34505e7a8d Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Mon, 26 Oct 2026 19:38:44 +0000 Subject: [PATCH 13/21] Drop the suites.json prefix from catalog scan task errors Signed-off-by: Anton Volkov --- dokimi/dokimi/commands/catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokimi/dokimi/commands/catalog.py b/dokimi/dokimi/commands/catalog.py index acfc10ef9..f96726609 100644 --- a/dokimi/dokimi/commands/catalog.py +++ b/dokimi/dokimi/commands/catalog.py @@ -102,7 +102,7 @@ def _scan_tasks(args: argparse.Namespace, root: Path, report: dict, as_json: boo try: tasks = read_task_manifest(root, tasks_subdir=args.tasks_subdir) except ValueError as exc: - raise SystemExit(f"suites.json: {exc}") from exc + raise SystemExit(str(exc)) from exc if not as_json: print(f"# tasks ({len(tasks)} declared under {root})") failed = False From 061682e127c94dffc15d46593b78f2dc2b4e693e Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:57:13 -0500 Subject: [PATCH 14/21] fix(catalog): report a deeply nested SKILL.md as an error row catalog scan --kind skills now lists a SKILL.md whose frontmatter nests too deeply for the YAML parser as an error row, instead of ending in a traceback. The strict frontmatter check used by the validator reports the same case as a frontmatter-parse error. Tests cover a SKILL.md that is a directory, one that is not UTF-8, and one with deeply nested frontmatter, for both scan and trigger-set. The task directory test also covers an unparsable task.toml, which raises ValueError. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/external/catalog.py | 2 +- dokimi/dokimi/external/validate.py | 2 +- dokimi/tests/test_catalog_command.py | 44 +++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/dokimi/dokimi/external/catalog.py b/dokimi/dokimi/external/catalog.py index 15cbb618d..8a1dfe308 100644 --- a/dokimi/dokimi/external/catalog.py +++ b/dokimi/dokimi/external/catalog.py @@ -107,7 +107,7 @@ def _scan_skill_files(root: Path) -> list[SkillCatalogEntry]: continue try: skill = load_skill(skill_file) - except (OSError, ValueError) as exc: + except (OSError, ValueError, RecursionError) as exc: found.append( SkillCatalogEntry( name=skill_file.parent.name, diff --git a/dokimi/dokimi/external/validate.py b/dokimi/dokimi/external/validate.py index 6c7cccf49..ff13c2c71 100644 --- a/dokimi/dokimi/external/validate.py +++ b/dokimi/dokimi/external/validate.py @@ -83,7 +83,7 @@ def _strict_meta(text: str) -> tuple[dict, Optional[Finding]]: """The frontmatter of ``text``, or a ``frontmatter-parse`` error carrying the YAML message.""" try: meta, _ = parse_frontmatter_strict(text) - except yaml.YAMLError as exc: + except (yaml.YAMLError, RecursionError) as exc: # nesting too deep for the YAML parser message = " ".join(str(exc).split()) # the YAML message is several lines; keep one return {}, Finding( "error", "frontmatter-parse", f"frontmatter is not valid YAML: {message}" diff --git a/dokimi/tests/test_catalog_command.py b/dokimi/tests/test_catalog_command.py index 5edbf61b0..a8d706b1a 100644 --- a/dokimi/tests/test_catalog_command.py +++ b/dokimi/tests/test_catalog_command.py @@ -12,6 +12,8 @@ from dokimi.cli import build_parser +_DEEP_FRONTMATTER = b"---\nname: " + b"[" * 100000 + b"\n---\nbody\n" + def _write_skill(root: Path, name: str) -> None: skill_dir = root / "skills" / name @@ -262,6 +264,26 @@ def test_catalog_scan_reports_clean_error_on_deeply_nested_skills_manifest(tmp_p assert f"invalid skills.yaml at {tmp_path / 'skills.yaml'}" in str(exc_info.value) +@pytest.mark.parametrize( + "content", + [None, b"\xff\xfe", _DEEP_FRONTMATTER], + ids=["directory", "non-utf8", "deep-frontmatter"], +) +def test_catalog_scan_reports_a_malformed_skill_as_an_error_row(tmp_path, capsys, content): + skill_md = tmp_path / "broken" / "SKILL.md" + skill_md.parent.mkdir() + if content is None: + skill_md.mkdir() + else: + skill_md.write_bytes(content) + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "skills"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert exc_info.value.code == 1 + assert f"broken\t-\t{skill_md.parent}\tERROR:" in capsys.readouterr().out + + def test_catalog_scan_reports_a_task_name_the_filesystem_rejects(tmp_path, monkeypatch): from dokimi.external import catalog as cat @@ -278,11 +300,18 @@ def too_long(path): assert entries[0].failed and "File name too long" in entries[0].reason -@pytest.mark.parametrize("content", [b"\xff\xfe", b"---\nname: a\n---\nbody\n"], ids=["non-utf8", "missing-description"]) +@pytest.mark.parametrize( + "content", + [b"\xff\xfe", b"---\nname: a\n---\nbody\n", _DEEP_FRONTMATTER, None], + ids=["non-utf8", "missing-description", "deep-frontmatter", "directory"], +) def test_catalog_trigger_set_reports_clean_error_on_malformed_skill_md(tmp_path, content): catalog = tmp_path / "skills" (catalog / "a").mkdir(parents=True) - (catalog / "a" / "SKILL.md").write_bytes(content) + if content is None: + (catalog / "a" / "SKILL.md").mkdir() + else: + (catalog / "a" / "SKILL.md").write_bytes(content) questions = tmp_path / "q.json" questions.write_text(json.dumps([{"id": "q1", "question": "hi"}]), encoding="utf-8") @@ -293,10 +322,17 @@ def test_catalog_trigger_set_reports_clean_error_on_malformed_skill_md(tmp_path, assert f"--catalog {catalog}:" in str(exc_info.value) -def test_catalog_scan_reports_clean_error_on_task_dir_without_instruction(tmp_path): +@pytest.mark.parametrize( + ("instruction", "task_toml"), + [(None, ""), ("Do the thing.", "[metadata\n")], + ids=["missing-instruction", "unparsable-task-toml"], +) +def test_catalog_scan_reports_clean_error_on_unloadable_task_dir(tmp_path, instruction, task_toml): task = tmp_path / "evaluation" / "harbor" / "tasks" / "t" task.mkdir(parents=True) - (task / "task.toml").write_text("", encoding="utf-8") + (task / "task.toml").write_text(task_toml, encoding="utf-8") + if instruction is not None: + (task / "instruction.md").write_text(instruction, encoding="utf-8") args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "tasks"]) with pytest.raises(SystemExit) as exc_info: From 263ccdefb9c8c53c10b35e8ff9fb22914967b537 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:01:26 -0500 Subject: [PATCH 15/21] fix(tools): refuse an unwritable --out-json before any probe call tools preference checks --out-json before the first model call, so a path that cannot be written (a file used as a parent directory, or a directory) exits 2 with a message instead of after a paid run. Missing parent directories are created, and the probe file is removed when the path did not exist before. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/tools.py | 19 ++++++++++++ dokimi/tests/test_tool_preference.py | 43 ++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/commands/tools.py b/dokimi/dokimi/commands/tools.py index 3724d0e37..228f3854b 100644 --- a/dokimi/dokimi/commands/tools.py +++ b/dokimi/dokimi/commands/tools.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse +import errno import json import math import sys @@ -114,6 +115,23 @@ def _save_result(data: dict, path: str) -> None: raise SystemExit(1) from None +def _check_out_json_writable(path: str) -> None: + """Exit 2 before any probe call when ``path`` cannot take the artifact; leaves no file behind.""" + target = Path(path) + try: + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_dir(): + raise IsADirectoryError(errno.EISDIR, "Is a directory", path) + existed = target.exists() or target.is_symlink() + with target.open("ab"): + pass + if not existed: + target.unlink() + except (OSError, ValueError) as exc: + print(f"Error: cannot write {path}: {exc}", file=sys.stderr) + raise SystemExit(2) from None + + def cmd_tools_preference(args: argparse.Namespace) -> None: from dokimi import tool_preference as tp @@ -138,6 +156,7 @@ def cmd_tools_preference(args: argparse.Namespace) -> None: return if not args.model: raise SystemExit("--model is required unless --dry-run") + _check_out_json_writable(args.out_json) def progress(done: int, total: int) -> None: if done % 100 == 0 or done == total: diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index bfc04bf3d..5461a651a 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -278,6 +278,45 @@ def boom(*a, **k): assert data["summary"]["n_errors"] == data["summary"]["n_calls"] == 16 +@pytest.mark.parametrize("blocked", ["file-as-parent", "directory"]) +def test_cli_refuses_an_unwritable_out_json_before_any_call(monkeypatch, tmp_path, capsys, blocked): + import dokimi.llm as llm + from dokimi.cli import build_parser + + calls = [] + + def fake(*a, **k): + calls.append(a) + raise RuntimeError("must not be reached") + + monkeypatch.setattr(llm, "chat_completion", fake) + if blocked == "file-as-parent": + (tmp_path / "blocker").write_text("x", encoding="utf-8") + out = tmp_path / "blocker" / "pref.json" + else: + out = tmp_path / "pref.json" + out.mkdir() + args = build_parser().parse_args(["tools", "preference", "--groups", str(GROUPS), "--model", "m", + "--reps", "1", "--factors", "assertive", "--out-json", str(out)]) + with pytest.raises(SystemExit) as exc: + args.func(args) + assert exc.value.code == 2 + assert calls == [] + err = capsys.readouterr().err + assert f"cannot write {out}: " in err + if blocked == "directory": + assert "Is a directory" in err + + +def test_out_json_check_creates_missing_parents_and_leaves_no_file(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + out = tmp_path / "a" / "b" / "pref.json" + _check_out_json_writable(str(out)) + assert out.parent.is_dir() + assert not out.exists() + + def test_repeated_factors_are_not_expanded_twice(doc, monkeypatch, tmp_path, capsys): from dokimi.cli import build_parser @@ -867,7 +906,7 @@ def test_an_unwritable_out_json_exits_with_a_message(monkeypatch, tmp_path, caps "--reps", "1", "--factors", "assertive", "--out-json", str(out)]) with pytest.raises(SystemExit) as exc: args.func(args) - assert exc.value.code == 1 + assert exc.value.code == 2 assert f"Error: cannot write {out}: " in capsys.readouterr().err @@ -881,7 +920,7 @@ def test_an_empty_out_json_exits_with_a_message(monkeypatch, capsys): "--reps", "1", "--factors", "assertive", "--out-json", ""]) with pytest.raises(SystemExit) as exc: args.func(args) - assert exc.value.code == 1 + assert exc.value.code == 2 assert "Error: cannot write : " in capsys.readouterr().err From ad114ec395b90143f31bd823cc8e5fb5c72a10cf Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:18:53 -0500 Subject: [PATCH 16/21] fix(tools): probe --out-json with the save's temp-file rule The out-json check now creates and removes a temp file beside the path the save writes to, instead of opening the target itself. A read-only existing file in a writable directory is accepted, a dangling symlink creates nothing at its target, and a symlink into a missing directory is refused before any probe call. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/tools.py | 13 ++++---- dokimi/tests/test_tool_preference.py | 47 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/dokimi/dokimi/commands/tools.py b/dokimi/dokimi/commands/tools.py index 228f3854b..3c4602ad6 100644 --- a/dokimi/dokimi/commands/tools.py +++ b/dokimi/dokimi/commands/tools.py @@ -10,6 +10,7 @@ import json import math import sys +import tempfile import time from pathlib import Path @@ -116,17 +117,17 @@ def _save_result(data: dict, path: str) -> None: def _check_out_json_writable(path: str) -> None: - """Exit 2 before any probe call when ``path`` cannot take the artifact; leaves no file behind.""" + """Exit 2 before any probe call when a save to ``path`` would fail, probed with a temp file that is removed.""" + from dokimi.artifacts import _write_target + target = Path(path) try: target.parent.mkdir(parents=True, exist_ok=True) - if target.is_dir(): + resolved = _write_target(target) + if resolved.is_dir(): raise IsADirectoryError(errno.EISDIR, "Is a directory", path) - existed = target.exists() or target.is_symlink() - with target.open("ab"): + with tempfile.NamedTemporaryFile(dir=resolved.parent, prefix=resolved.name + ".", suffix=".tmp"): pass - if not existed: - target.unlink() except (OSError, ValueError) as exc: print(f"Error: cannot write {path}: {exc}", file=sys.stderr) raise SystemExit(2) from None diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index 5461a651a..01e100a36 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -7,6 +7,7 @@ import copy import json +import os import threading import time from pathlib import Path @@ -317,6 +318,52 @@ def test_out_json_check_creates_missing_parents_and_leaves_no_file(tmp_path): assert not out.exists() +@pytest.mark.skipif(os.name == "nt", reason="os.replace refuses a read-only destination on Windows, " + "which the POSIX rule pinned here does not model") +def test_out_json_check_accepts_a_read_only_file_in_a_writable_dir(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + out = tmp_path / "pref.json" + out.write_text("{}", encoding="utf-8") + out.chmod(0o444) + try: + if os.access(out, os.W_OK): + pytest.skip("chmod 0444 does not block writes here (for example, running as root)") + _check_out_json_writable(str(out)) # the save replaces the file through its directory + finally: + out.chmod(0o644) + + +def _symlink_or_skip(link: Path, target: Path) -> None: + try: + link.symlink_to(target) + except OSError as exc: # Windows needs Developer Mode or admin rights to create a symlink + pytest.skip(f"cannot create a symlink here: {exc}") + + +def test_out_json_check_leaves_no_file_at_a_dangling_symlink_target(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link.json" + _symlink_or_skip(link, real / "t.json") + _check_out_json_writable(str(link)) + assert not (real / "t.json").exists() + assert link.is_symlink() + + +def test_out_json_check_refuses_a_dangling_symlink_into_a_missing_dir(tmp_path, capsys): + from dokimi.commands.tools import _check_out_json_writable + + link = tmp_path / "link.json" + _symlink_or_skip(link, tmp_path / "missing" / "t.json") + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(link)) + assert exc.value.code == 2 + assert "cannot write" in capsys.readouterr().err + + def test_repeated_factors_are_not_expanded_twice(doc, monkeypatch, tmp_path, capsys): from dokimi.cli import build_parser From 82407cdb70b5a8c406a2524a1aa424c84c52a204 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:57:32 -0500 Subject: [PATCH 17/21] test(tools): pin the out-json probe against read-only and symlinked targets Three tests cover a writable file in a read-only directory, a symlink whose target sits in a read-only directory, and a symlink to a directory. Each refuses with exit 2 before any probe call. The read-only cases skip on Windows and when the process can write through chmod 0555 (root). Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/tests/test_tool_preference.py | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index 01e100a36..0070712e6 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -364,6 +364,59 @@ def test_out_json_check_refuses_a_dangling_symlink_into_a_missing_dir(tmp_path, assert "cannot write" in capsys.readouterr().err +@pytest.mark.skipif(os.name == "nt", reason="chmod 0555 does not make a directory read-only on Windows") +def test_out_json_check_refuses_a_writable_file_in_a_read_only_dir(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + ro = tmp_path / "ro" + ro.mkdir() + out = ro / "pref.json" + out.write_text("{}", encoding="utf-8") + ro.chmod(0o555) + try: + if os.access(ro, os.W_OK): + pytest.skip("chmod 0555 does not block writes here (for example, running as root)") + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(out)) + assert exc.value.code == 2 + finally: + ro.chmod(0o755) + + +@pytest.mark.skipif(os.name == "nt", reason="chmod 0555 does not make a directory read-only on Windows") +def test_out_json_check_probes_the_symlink_target_directory(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + ro = tmp_path / "ro" + ro.mkdir() + out_dir = tmp_path / "out" + out_dir.mkdir() + link = out_dir / "link.json" + _symlink_or_skip(link, ro / "t.json") + ro.chmod(0o555) + try: + if os.access(ro, os.W_OK): + pytest.skip("chmod 0555 does not block writes here (for example, running as root)") + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(link)) + assert exc.value.code == 2 + finally: + ro.chmod(0o755) + + +def test_out_json_check_refuses_a_symlink_to_a_directory(tmp_path, capsys): + from dokimi.commands.tools import _check_out_json_writable + + real = tmp_path / "dir" + real.mkdir() + link = tmp_path / "link.json" + _symlink_or_skip(link, real) + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(link)) + assert exc.value.code == 2 + assert "Is a directory" in capsys.readouterr().err + + def test_repeated_factors_are_not_expanded_twice(doc, monkeypatch, tmp_path, capsys): from dokimi.cli import build_parser From 41466dd77fc409c738cfc30ff83171fedec74632 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:59:08 -0500 Subject: [PATCH 18/21] test(tools): move the out-json pre-flight tests to their own file test_tool_preference.py passed the 1000-line growth cap, so the out-json pre-flight tests move to test_tool_preference_out_json.py. The tests are unchanged apart from the moved location and an unused import. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/tests/test_tool_preference.py | 139 ---------------- dokimi/tests/test_tool_preference_out_json.py | 151 ++++++++++++++++++ 2 files changed, 151 insertions(+), 139 deletions(-) create mode 100644 dokimi/tests/test_tool_preference_out_json.py diff --git a/dokimi/tests/test_tool_preference.py b/dokimi/tests/test_tool_preference.py index 0070712e6..034f99745 100644 --- a/dokimi/tests/test_tool_preference.py +++ b/dokimi/tests/test_tool_preference.py @@ -7,7 +7,6 @@ import copy import json -import os import threading import time from pathlib import Path @@ -279,144 +278,6 @@ def boom(*a, **k): assert data["summary"]["n_errors"] == data["summary"]["n_calls"] == 16 -@pytest.mark.parametrize("blocked", ["file-as-parent", "directory"]) -def test_cli_refuses_an_unwritable_out_json_before_any_call(monkeypatch, tmp_path, capsys, blocked): - import dokimi.llm as llm - from dokimi.cli import build_parser - - calls = [] - - def fake(*a, **k): - calls.append(a) - raise RuntimeError("must not be reached") - - monkeypatch.setattr(llm, "chat_completion", fake) - if blocked == "file-as-parent": - (tmp_path / "blocker").write_text("x", encoding="utf-8") - out = tmp_path / "blocker" / "pref.json" - else: - out = tmp_path / "pref.json" - out.mkdir() - args = build_parser().parse_args(["tools", "preference", "--groups", str(GROUPS), "--model", "m", - "--reps", "1", "--factors", "assertive", "--out-json", str(out)]) - with pytest.raises(SystemExit) as exc: - args.func(args) - assert exc.value.code == 2 - assert calls == [] - err = capsys.readouterr().err - assert f"cannot write {out}: " in err - if blocked == "directory": - assert "Is a directory" in err - - -def test_out_json_check_creates_missing_parents_and_leaves_no_file(tmp_path): - from dokimi.commands.tools import _check_out_json_writable - - out = tmp_path / "a" / "b" / "pref.json" - _check_out_json_writable(str(out)) - assert out.parent.is_dir() - assert not out.exists() - - -@pytest.mark.skipif(os.name == "nt", reason="os.replace refuses a read-only destination on Windows, " - "which the POSIX rule pinned here does not model") -def test_out_json_check_accepts_a_read_only_file_in_a_writable_dir(tmp_path): - from dokimi.commands.tools import _check_out_json_writable - - out = tmp_path / "pref.json" - out.write_text("{}", encoding="utf-8") - out.chmod(0o444) - try: - if os.access(out, os.W_OK): - pytest.skip("chmod 0444 does not block writes here (for example, running as root)") - _check_out_json_writable(str(out)) # the save replaces the file through its directory - finally: - out.chmod(0o644) - - -def _symlink_or_skip(link: Path, target: Path) -> None: - try: - link.symlink_to(target) - except OSError as exc: # Windows needs Developer Mode or admin rights to create a symlink - pytest.skip(f"cannot create a symlink here: {exc}") - - -def test_out_json_check_leaves_no_file_at_a_dangling_symlink_target(tmp_path): - from dokimi.commands.tools import _check_out_json_writable - - real = tmp_path / "real" - real.mkdir() - link = tmp_path / "link.json" - _symlink_or_skip(link, real / "t.json") - _check_out_json_writable(str(link)) - assert not (real / "t.json").exists() - assert link.is_symlink() - - -def test_out_json_check_refuses_a_dangling_symlink_into_a_missing_dir(tmp_path, capsys): - from dokimi.commands.tools import _check_out_json_writable - - link = tmp_path / "link.json" - _symlink_or_skip(link, tmp_path / "missing" / "t.json") - with pytest.raises(SystemExit) as exc: - _check_out_json_writable(str(link)) - assert exc.value.code == 2 - assert "cannot write" in capsys.readouterr().err - - -@pytest.mark.skipif(os.name == "nt", reason="chmod 0555 does not make a directory read-only on Windows") -def test_out_json_check_refuses_a_writable_file_in_a_read_only_dir(tmp_path): - from dokimi.commands.tools import _check_out_json_writable - - ro = tmp_path / "ro" - ro.mkdir() - out = ro / "pref.json" - out.write_text("{}", encoding="utf-8") - ro.chmod(0o555) - try: - if os.access(ro, os.W_OK): - pytest.skip("chmod 0555 does not block writes here (for example, running as root)") - with pytest.raises(SystemExit) as exc: - _check_out_json_writable(str(out)) - assert exc.value.code == 2 - finally: - ro.chmod(0o755) - - -@pytest.mark.skipif(os.name == "nt", reason="chmod 0555 does not make a directory read-only on Windows") -def test_out_json_check_probes_the_symlink_target_directory(tmp_path): - from dokimi.commands.tools import _check_out_json_writable - - ro = tmp_path / "ro" - ro.mkdir() - out_dir = tmp_path / "out" - out_dir.mkdir() - link = out_dir / "link.json" - _symlink_or_skip(link, ro / "t.json") - ro.chmod(0o555) - try: - if os.access(ro, os.W_OK): - pytest.skip("chmod 0555 does not block writes here (for example, running as root)") - with pytest.raises(SystemExit) as exc: - _check_out_json_writable(str(link)) - assert exc.value.code == 2 - finally: - ro.chmod(0o755) - - -def test_out_json_check_refuses_a_symlink_to_a_directory(tmp_path, capsys): - from dokimi.commands.tools import _check_out_json_writable - - real = tmp_path / "dir" - real.mkdir() - link = tmp_path / "link.json" - _symlink_or_skip(link, real) - with pytest.raises(SystemExit) as exc: - _check_out_json_writable(str(link)) - assert exc.value.code == 2 - assert "Is a directory" in capsys.readouterr().err - - def test_repeated_factors_are_not_expanded_twice(doc, monkeypatch, tmp_path, capsys): from dokimi.cli import build_parser diff --git a/dokimi/tests/test_tool_preference_out_json.py b/dokimi/tests/test_tool_preference_out_json.py new file mode 100644 index 000000000..9f1839254 --- /dev/null +++ b/dokimi/tests/test_tool_preference_out_json.py @@ -0,0 +1,151 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""The --out-json pre-flight of `tools preference`: refused before any probe call, or accepted.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +GROUPS = Path(__file__).resolve().parent / "fixtures" / "tool_preference" / "groups.json" + + +@pytest.mark.parametrize("blocked", ["file-as-parent", "directory"]) +def test_cli_refuses_an_unwritable_out_json_before_any_call(monkeypatch, tmp_path, capsys, blocked): + import dokimi.llm as llm + from dokimi.cli import build_parser + + calls = [] + + def fake(*a, **k): + calls.append(a) + raise RuntimeError("must not be reached") + + monkeypatch.setattr(llm, "chat_completion", fake) + if blocked == "file-as-parent": + (tmp_path / "blocker").write_text("x", encoding="utf-8") + out = tmp_path / "blocker" / "pref.json" + else: + out = tmp_path / "pref.json" + out.mkdir() + args = build_parser().parse_args(["tools", "preference", "--groups", str(GROUPS), "--model", "m", + "--reps", "1", "--factors", "assertive", "--out-json", str(out)]) + with pytest.raises(SystemExit) as exc: + args.func(args) + assert exc.value.code == 2 + assert calls == [] + err = capsys.readouterr().err + assert f"cannot write {out}: " in err + if blocked == "directory": + assert "Is a directory" in err + + +def test_out_json_check_creates_missing_parents_and_leaves_no_file(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + out = tmp_path / "a" / "b" / "pref.json" + _check_out_json_writable(str(out)) + assert out.parent.is_dir() + assert not out.exists() + + +@pytest.mark.skipif(os.name == "nt", reason="os.replace refuses a read-only destination on Windows, " + "which the POSIX rule pinned here does not model") +def test_out_json_check_accepts_a_read_only_file_in_a_writable_dir(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + out = tmp_path / "pref.json" + out.write_text("{}", encoding="utf-8") + out.chmod(0o444) + try: + if os.access(out, os.W_OK): + pytest.skip("chmod 0444 does not block writes here (for example, running as root)") + _check_out_json_writable(str(out)) # the save replaces the file through its directory + finally: + out.chmod(0o644) + + +def _symlink_or_skip(link: Path, target: Path) -> None: + try: + link.symlink_to(target) + except OSError as exc: # Windows needs Developer Mode or admin rights to create a symlink + pytest.skip(f"cannot create a symlink here: {exc}") + + +def test_out_json_check_leaves_no_file_at_a_dangling_symlink_target(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link.json" + _symlink_or_skip(link, real / "t.json") + _check_out_json_writable(str(link)) + assert not (real / "t.json").exists() + assert link.is_symlink() + + +def test_out_json_check_refuses_a_dangling_symlink_into_a_missing_dir(tmp_path, capsys): + from dokimi.commands.tools import _check_out_json_writable + + link = tmp_path / "link.json" + _symlink_or_skip(link, tmp_path / "missing" / "t.json") + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(link)) + assert exc.value.code == 2 + assert "cannot write" in capsys.readouterr().err + + +@pytest.mark.skipif(os.name == "nt", reason="chmod 0555 does not make a directory read-only on Windows") +def test_out_json_check_refuses_a_writable_file_in_a_read_only_dir(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + ro = tmp_path / "ro" + ro.mkdir() + out = ro / "pref.json" + out.write_text("{}", encoding="utf-8") + ro.chmod(0o555) + try: + if os.access(ro, os.W_OK): + pytest.skip("chmod 0555 does not block writes here (for example, running as root)") + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(out)) + assert exc.value.code == 2 + finally: + ro.chmod(0o755) + + +@pytest.mark.skipif(os.name == "nt", reason="chmod 0555 does not make a directory read-only on Windows") +def test_out_json_check_probes_the_symlink_target_directory(tmp_path): + from dokimi.commands.tools import _check_out_json_writable + + ro = tmp_path / "ro" + ro.mkdir() + out_dir = tmp_path / "out" + out_dir.mkdir() + link = out_dir / "link.json" + _symlink_or_skip(link, ro / "t.json") + ro.chmod(0o555) + try: + if os.access(ro, os.W_OK): + pytest.skip("chmod 0555 does not block writes here (for example, running as root)") + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(link)) + assert exc.value.code == 2 + finally: + ro.chmod(0o755) + + +def test_out_json_check_refuses_a_symlink_to_a_directory(tmp_path, capsys): + from dokimi.commands.tools import _check_out_json_writable + + real = tmp_path / "dir" + real.mkdir() + link = tmp_path / "link.json" + _symlink_or_skip(link, real) + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(link)) + assert exc.value.code == 2 + assert "Is a directory" in capsys.readouterr().err From 8f0444060ae385977235855b75d89759f1609e9d Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Mon, 26 Oct 2026 21:11:23 +0000 Subject: [PATCH 19/21] fix(tools): probe --out-json with the save's temp file name Signed-off-by: Anton Volkov --- dokimi/dokimi/commands/tools.py | 7 +++++-- dokimi/tests/test_tool_preference_out_json.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/commands/tools.py b/dokimi/dokimi/commands/tools.py index 3c4602ad6..5908fab7a 100644 --- a/dokimi/dokimi/commands/tools.py +++ b/dokimi/dokimi/commands/tools.py @@ -9,8 +9,9 @@ import errno import json import math +import os import sys -import tempfile +import threading import time from pathlib import Path @@ -126,8 +127,10 @@ def _check_out_json_writable(path: str) -> None: resolved = _write_target(target) if resolved.is_dir(): raise IsADirectoryError(errno.EISDIR, "Is a directory", path) - with tempfile.NamedTemporaryFile(dir=resolved.parent, prefix=resolved.name + ".", suffix=".tmp"): + tmp = resolved.with_name(f"{resolved.name}.{os.getpid()}.{threading.get_ident()}.tmp") # the save's name + with tmp.open("xb"): pass + tmp.unlink() except (OSError, ValueError) as exc: print(f"Error: cannot write {path}: {exc}", file=sys.stderr) raise SystemExit(2) from None diff --git a/dokimi/tests/test_tool_preference_out_json.py b/dokimi/tests/test_tool_preference_out_json.py index 9f1839254..83cd3f35e 100644 --- a/dokimi/tests/test_tool_preference_out_json.py +++ b/dokimi/tests/test_tool_preference_out_json.py @@ -138,6 +138,21 @@ def test_out_json_check_probes_the_symlink_target_directory(tmp_path): ro.chmod(0o755) +def test_out_json_check_refuses_a_name_too_long_for_the_save_temp_file(tmp_path, capsys): + from dokimi.commands.tools import _check_out_json_writable + + out = tmp_path / ("a" * 230 + ".json") + try: + out.touch() + except OSError: + pytest.skip("this file system takes no 235-character name") + out.unlink() + with pytest.raises(SystemExit) as exc: + _check_out_json_writable(str(tmp_path / ("a" * 235 + ".json"))) + assert exc.value.code == 2 + assert "cannot write" in capsys.readouterr().err + + def test_out_json_check_refuses_a_symlink_to_a_directory(tmp_path, capsys): from dokimi.commands.tools import _check_out_json_writable From 2a23d6a6244fe7993a179a76a1327689daee85bd Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Tue, 27 Oct 2026 02:16:45 -0700 Subject: [PATCH 20/21] fix(catalog): a too-deep SKILL.md listed in skills.yaml is an error row The manifest branch (_skill_entry) caught only OSError and ValueError around load_skill, so a deeply nested frontmatter ended catalog scan in a RecursionError traceback; the no-manifest branch already caught it. Signed-off-by: Alexander Rybkin --- dokimi/dokimi/external/catalog.py | 2 +- dokimi/tests/test_catalog_command.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/external/catalog.py b/dokimi/dokimi/external/catalog.py index 8a1dfe308..af00cfcd9 100644 --- a/dokimi/dokimi/external/catalog.py +++ b/dokimi/dokimi/external/catalog.py @@ -221,7 +221,7 @@ def failed(error: str, path: Optional[Path] = None) -> SkillCatalogEntry: return failed(f"{source} escapes root: {catalog_path!r}") try: skill = load_skill(path) - except (OSError, ValueError) as exc: + except (OSError, ValueError, RecursionError) as exc: return failed(str(exc), path) # The directory is inside the root; a SKILL.md symlinked out of it is not. if not _within(skill.path, root): diff --git a/dokimi/tests/test_catalog_command.py b/dokimi/tests/test_catalog_command.py index ecd6c0f7d..f0ed09c9f 100644 --- a/dokimi/tests/test_catalog_command.py +++ b/dokimi/tests/test_catalog_command.py @@ -284,6 +284,22 @@ def test_catalog_scan_reports_a_malformed_skill_as_an_error_row(tmp_path, capsys assert f"broken\t-\t{skill_md.parent}\tERROR:" in capsys.readouterr().out +def test_catalog_scan_reports_a_deep_manifest_listed_skill_as_an_error_row(tmp_path, capsys): + _write_skill(tmp_path, "good") + deep = tmp_path / "skills" / "deep" + deep.mkdir() + (deep / "SKILL.md").write_bytes(_DEEP_FRONTMATTER) + (tmp_path / "skills.yaml").write_text("- name: good\n- name: deep\n", encoding="utf-8") + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "skills"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + assert exc_info.value.code == 1 + out = capsys.readouterr().out + assert f"deep\t-\t{deep}\tERROR:" in out + assert f"good\t-\t{tmp_path / 'skills' / 'good'}\tok" in out + + def test_catalog_scan_reports_a_task_name_the_filesystem_rejects(tmp_path, monkeypatch): from dokimi.external import catalog as cat From 3d291877bc3ed71b90495b1c3f5e3f70dfc32004 Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Tue, 27 Oct 2026 02:18:02 -0700 Subject: [PATCH 21/21] fix(catalog): reject falsy wrong-typed suites and tasks in suites.json `data.get("suites") or []` turned {}, 0, false and "" into an empty list before the shape check, so such a manifest scanned as 0 declared, rc 0. Only null or a missing key now means empty, as in skills.yaml. Signed-off-by: Alexander Rybkin --- dokimi/dokimi/external/catalog.py | 6 ++++-- dokimi/tests/test_catalog_command.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/dokimi/dokimi/external/catalog.py b/dokimi/dokimi/external/catalog.py index af00cfcd9..9a916b16f 100644 --- a/dokimi/dokimi/external/catalog.py +++ b/dokimi/dokimi/external/catalog.py @@ -238,12 +238,14 @@ def invalid(why: str) -> ValueError: if not isinstance(data, dict): raise invalid(f"top level must be an object, got {type(data).__name__}") - suites = data.get("suites") or [] + suites = data.get("suites") + suites = [] if suites is None else suites if not isinstance(suites, list) or not all(isinstance(s, dict) for s in suites): raise invalid("suites must be a list of objects") tasks: list[dict] = [] for suite in suites: - suite_tasks = suite.get("tasks") or [] + suite_tasks = suite.get("tasks") + suite_tasks = [] if suite_tasks is None else suite_tasks if not isinstance(suite_tasks, list) or not all(isinstance(t, dict) for t in suite_tasks): raise invalid("each suite's tasks must be a list of objects") tasks.extend(suite_tasks) diff --git a/dokimi/tests/test_catalog_command.py b/dokimi/tests/test_catalog_command.py index f0ed09c9f..f30c258b4 100644 --- a/dokimi/tests/test_catalog_command.py +++ b/dokimi/tests/test_catalog_command.py @@ -242,7 +242,8 @@ def test_catalog_scan_reports_clean_error_on_non_utf8_suites_manifest(tmp_path): assert "invalid suites.json" in str(exc_info.value) -@pytest.mark.parametrize("text", ["[]", "null", '{"suites": 5}', '{"suites": [1]}', '{"suites": [{"tasks": [3]}]}', +@pytest.mark.parametrize("text", ["[]", "null", '{"suites": 5}', '{"suites": {}}', '{"suites": 0}', + '{"suites": [{"tasks": {}}]}', '{"suites": [1]}', '{"suites": [{"tasks": [3]}]}', '{"suites": [{"tasks": 3}]}', pytest.param("[" * 200000, id="deeply-nested")]) def test_catalog_scan_reports_clean_error_on_wrong_shaped_suites_manifest(tmp_path, text): manifest = tmp_path / "evaluation" / "harbor" / "suites.json" @@ -255,6 +256,16 @@ def test_catalog_scan_reports_clean_error_on_wrong_shaped_suites_manifest(tmp_pa assert f"invalid suites.json at {manifest}" in str(exc_info.value) +@pytest.mark.parametrize("text", ["{}", '{"suites": null}', '{"suites": [{"tasks": null}]}']) +def test_catalog_scan_reads_a_null_or_missing_suites_manifest_list_as_empty(tmp_path, capsys, text): + (tmp_path / "evaluation" / "harbor" / "tasks").mkdir(parents=True) + (tmp_path / "evaluation" / "harbor" / "suites.json").write_text(text, encoding="utf-8") + + args = build_parser().parse_args(["catalog", "scan", "--root", str(tmp_path), "--kind", "tasks"]) + args.func(args) + assert f"# tasks (0 declared under {tmp_path})" in capsys.readouterr().out + + def test_catalog_scan_reports_clean_error_on_deeply_nested_skills_manifest(tmp_path): (tmp_path / "skills.yaml").write_text("[" * 200000, encoding="utf-8")