From 9ee05ee33d5893982d373d3c025792fbf784f38a Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:57:45 -0500 Subject: [PATCH 1/2] feat(repo): repo fetch --json, repo validate and budget --out, validate --fail-on-warning Bring the repo commands in line with each other. fetch prints {sha, git_dir} as JSON with --json. validate and budget write their full report to a file with --out, as scan and inspect do. validate exits 1 on a warning with --fail-on-warning, as budget does. Default output and exit codes are unchanged. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- ...30000_vchamarthi_repo-cli-output-parity.md | 12 ++++++ dokimi/docs/repo-evaluation-contract.md | 6 +-- dokimi/dokimi/commands/repo.py | 24 ++++++++++- dokimi/tests/test_repo_budget.py | 11 +++++ dokimi/tests/test_repo_manifest.py | 42 +++++++++++++++++++ 5 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 dokimi/changelog.d/20261021_130000_vchamarthi_repo-cli-output-parity.md diff --git a/dokimi/changelog.d/20261021_130000_vchamarthi_repo-cli-output-parity.md b/dokimi/changelog.d/20261021_130000_vchamarthi_repo-cli-output-parity.md new file mode 100644 index 00000000..8c7e7573 --- /dev/null +++ b/dokimi/changelog.d/20261021_130000_vchamarthi_repo-cli-output-parity.md @@ -0,0 +1,12 @@ +### Added + +- **`repo fetch --json`** prints `{"sha", "git_dir"}` as JSON, so a script reads the + pinned commit without splitting the tab-separated line. +- **`repo validate --out FILE`** and **`repo budget --out FILE`** write the full + report as JSON, as `repo scan` and `repo inspect` already do. The summary (or + `--json`) still prints. +- **`repo validate --fail-on-warning`** exits 1 when the report has any warning, + as `repo budget --fail-on-warning` does, so a CI job can gate on a guidance + glob that matches nothing or a task base SHA that is not 40 characters. + +The default output and exit codes of all three commands are unchanged. diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index d4bda8dc..de103473 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -15,8 +15,8 @@ SPDX-License-Identifier: Apache-2.0 > subject kind: > > ```bash -> python cli.py repo validate --manifest [--json] -> python cli.py repo fetch # bare, blob-less clone cached per URL +> python cli.py repo validate --manifest [--json] [--out FILE] [--fail-on-warning] +> python cli.py repo fetch [--json] # bare, blob-less clone cached per URL > ``` > > Slice S3 adds harness profiles (§6) and the reach probe (§7): @@ -45,7 +45,7 @@ SPDX-License-Identifier: Apache-2.0 > Usage: > > ```bash -> python cli.py repo budget [--harness claude-code] [--json] [--fail-on-warning] +> python cli.py repo budget [--harness claude-code] [--out FILE] [--json] [--fail-on-warning] > ``` > > `repo inspect` (§8.1) needs no manifest: a static look at what the tree offers an agent. diff --git a/dokimi/dokimi/commands/repo.py b/dokimi/dokimi/commands/repo.py index 13c6a11b..26d11f5f 100644 --- a/dokimi/dokimi/commands/repo.py +++ b/dokimi/dokimi/commands/repo.py @@ -37,6 +37,12 @@ def cmd_repo_validate(args: argparse.Namespace) -> None: if open_base is None: report.notes.append("task bases were not checked: the source has no git history") + if args.out: + try: + Path(args.out).write_text(json.dumps(report.to_dict(), indent=2) + "\n", encoding="utf-8") + except OSError as exc: + print(f"✗ cannot write {args.out}: {exc}", file=sys.stderr) + sys.exit(1) if args.json: print(json.dumps(report.to_dict(), indent=2)) else: @@ -58,7 +64,7 @@ def cmd_repo_validate(args: argparse.Namespace) -> None: print(f" note: {note}") for error in report.errors: print(f" error: {error}", file=sys.stderr) - if not report.ok: + if not report.ok or (args.fail_on_warning and report.warnings): sys.exit(1) @@ -167,7 +173,10 @@ def cmd_repo_fetch(args: argparse.Namespace) -> None: print(f"✗ {exc}", file=sys.stderr) sys.exit(1) assert isinstance(tree, GitTree) - print(f"{tree.sha}\t{tree.git_dir}") + if args.json: + print(json.dumps({"sha": tree.sha, "git_dir": str(tree.git_dir)}, indent=2)) + else: + print(f"{tree.sha}\t{tree.git_dir}") def cmd_repo_reach(args: argparse.Namespace) -> None: @@ -240,6 +249,12 @@ def cmd_repo_budget(args: argparse.Namespace) -> None: print(f"✗ {exc}", file=sys.stderr) sys.exit(1) payload = budget(tree, profile.guidance_names, profile.import_max_depth) + if args.out: + try: + Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + except OSError as exc: + print(f"✗ cannot write {args.out}: {exc}", file=sys.stderr) + sys.exit(1) if args.json: print(json.dumps(payload, indent=2)) else: @@ -339,7 +354,10 @@ def register(sub, positive_int) -> None: help="Read the manifest from this local file instead of from the tree, as if it were " "at --manifest (for a repository that has not added it yet)") validate_p.add_argument("--cache-dir", default=None, dest="cache_dir", help=cache_help) + validate_p.add_argument("--out", default=None, help="Write the full report as JSON to this file") validate_p.add_argument("--json", action="store_true", help="Print the full report as JSON") + validate_p.add_argument("--fail-on-warning", action="store_true", dest="fail_on_warning", + help="Exit 1 when the report has any warning (for a CI gate)") validate_p.set_defaults(func=cmd_repo_validate) scan_p = repo_sub.add_parser( @@ -386,6 +404,7 @@ def register(sub, positive_int) -> None: fetch_p = repo_sub.add_parser("fetch", help="Clone/fetch a pinned source and print its commit SHA") fetch_p.add_argument("source", help=source_help) + fetch_p.add_argument("--json", action="store_true", help="Print {sha, git_dir} as JSON instead of tab-separated") fetch_p.add_argument("--cache-dir", default=None, dest="cache_dir", help=cache_help) fetch_p.set_defaults(func=cmd_repo_fetch) @@ -439,6 +458,7 @@ def register(sub, positive_int) -> None: help="Built-in profile name or a harness_profile.v1 YAML path; its guidance names " "supply the file names, but the classification rules (root vs nested, off rule) are " "hardcoded to Claude Code until other harnesses are measured (default: claude-code)") + budget_p.add_argument("--out", default=None, help="Write the full inventory as JSON to this file") budget_p.add_argument("--json", action="store_true", help="Print the full inventory as JSON") budget_p.add_argument("--fail-on-warning", action="store_true", dest="fail_on_warning", help="Exit 1 when any warning is printed (for a CI gate)") diff --git a/dokimi/tests/test_repo_budget.py b/dokimi/tests/test_repo_budget.py index 9717219a..4a733c64 100644 --- a/dokimi/tests/test_repo_budget.py +++ b/dokimi/tests/test_repo_budget.py @@ -319,6 +319,17 @@ def test_cli_json_and_bad_source(tmp_path, capsys): assert exc.value.code == 1 +def test_cli_out_writes_the_inventory(tmp_path, capsys): + _tree(tmp_path, {"AGENTS.md": "a"}) + dest = tmp_path / "budget.json" + out = _run(["repo", "budget", str(tmp_path), "--out", str(dest)], capsys).out + assert json.loads(dest.read_text(encoding="utf-8"))["totals"]["start"] == 1 + assert "| start | guidance | AGENTS.md | 1 |" in out # the table still prints + with pytest.raises(SystemExit) as exc: + _run(["repo", "budget", str(tmp_path), "--out", str(tmp_path / "no" / "dir" / "b.json")], capsys) + assert exc.value.code == 1 + + def test_imports_must_exist_to_be_counted(tmp_path): """Real imports are counted; missing files and imports in code are not.""" tree = _tree(tmp_path, { diff --git a/dokimi/tests/test_repo_manifest.py b/dokimi/tests/test_repo_manifest.py index bdd81c26..cfe4da69 100644 --- a/dokimi/tests/test_repo_manifest.py +++ b/dokimi/tests/test_repo_manifest.py @@ -421,6 +421,36 @@ def test_repo_validate_cli(tmp_path, capsys): assert "does not equal the directory name" in capsys.readouterr().err +def test_repo_validate_fail_on_warning(tmp_path, capsys): + guidance = {**MANIFEST["guidance"], "also_scan": ["no-such-file-*.md"]} + root = _repo(tmp_path, {**MANIFEST, "guidance": guidance}) + argv = ["repo", "validate", str(root), "--manifest", "dev/evals/repo-eval.yaml"] + _run_cli(argv) # a warning alone still passes by default + assert "warning: guidance/also_scan matches no file" in capsys.readouterr().out + with pytest.raises(SystemExit) as exc: + _run_cli([*argv, "--fail-on-warning"]) + assert exc.value.code == 1 + assert "warning:" in capsys.readouterr().out + + +def test_repo_validate_out_writes_the_report(tmp_path, capsys): + root = _repo(tmp_path) + dest = tmp_path / "validate.json" + argv = ["repo", "validate", str(root), "--manifest", "dev/evals/repo-eval.yaml", "--out", str(dest)] + _run_cli(argv) + written = json.loads(dest.read_text(encoding="utf-8")) + assert written["ok"] is True and written["tasks"]["count"] == 2 + assert "tasks:" in capsys.readouterr().out # the summary still prints + + _run_cli([*argv, "--json"]) + assert json.loads(capsys.readouterr().out) == written + + with pytest.raises(SystemExit) as exc: + _run_cli([*argv[:-1], str(tmp_path / "no" / "dir" / "v.json")]) + assert exc.value.code == 1 + assert "cannot write" in capsys.readouterr().err + + def test_repo_fetch_cli(tmp_path, capsys): root = _repo(tmp_path) sha = _git_commit(root) @@ -430,6 +460,18 @@ def test_repo_fetch_cli(tmp_path, capsys): _run_cli(["repo", "fetch", str(root)]) +def test_repo_fetch_cli_json(tmp_path, capsys, monkeypatch): + from dokimi.repos.source import GitTree + + sha = "0123456789abcdef0123456789abcdef01234567" + git_dir = tmp_path / "bare.git" + monkeypatch.setattr("dokimi.repos.open_source", lambda source, cache: GitTree(git_dir, sha)) + _run_cli(["repo", "fetch", "https://example.invalid/r.git@main", "--json"]) + assert json.loads(capsys.readouterr().out) == {"sha": sha, "git_dir": str(git_dir)} + _run_cli(["repo", "fetch", "https://example.invalid/r.git@main"]) + assert capsys.readouterr().out == f"{sha}\t{git_dir}\n" + + def test_repo_subject_kind(tmp_path): path = tmp_path / "subject.toml" path.write_text( From 6267044c953d11a08368ed5ef80a10c9adbe0c02 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Wed, 21 Oct 2026 12:44:18 -0700 Subject: [PATCH 2/2] refactor(repo): one _write_json helper for validate and budget --out Signed-off-by: Nikolay Petrov --- dokimi/dokimi/commands/repo.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/dokimi/dokimi/commands/repo.py b/dokimi/dokimi/commands/repo.py index 26d11f5f..2b5e82b1 100644 --- a/dokimi/dokimi/commands/repo.py +++ b/dokimi/dokimi/commands/repo.py @@ -20,6 +20,15 @@ def _normalize_whitespace(text: str, max_len: int | None = 100) -> str: return printable[:max_len] +def _write_json(path: str, payload: dict) -> None: + """Write a --out report as JSON, or exit 1 with `cannot write`.""" + try: + Path(path).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + except OSError as exc: + print(f"✗ cannot write {path}: {exc}", file=sys.stderr) + sys.exit(1) + + def cmd_repo_validate(args: argparse.Namespace) -> None: """Validate a repository's repo-eval.yaml and its tasks against one snapshot.""" from dokimi.repos import ManifestError, RepoSource, SourceError, check_repo, open_source @@ -38,11 +47,7 @@ def cmd_repo_validate(args: argparse.Namespace) -> None: report.notes.append("task bases were not checked: the source has no git history") if args.out: - try: - Path(args.out).write_text(json.dumps(report.to_dict(), indent=2) + "\n", encoding="utf-8") - except OSError as exc: - print(f"✗ cannot write {args.out}: {exc}", file=sys.stderr) - sys.exit(1) + _write_json(args.out, report.to_dict()) if args.json: print(json.dumps(report.to_dict(), indent=2)) else: @@ -250,11 +255,7 @@ def cmd_repo_budget(args: argparse.Namespace) -> None: sys.exit(1) payload = budget(tree, profile.guidance_names, profile.import_max_depth) if args.out: - try: - Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - except OSError as exc: - print(f"✗ cannot write {args.out}: {exc}", file=sys.stderr) - sys.exit(1) + _write_json(args.out, payload) if args.json: print(json.dumps(payload, indent=2)) else: