Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions dokimi/docs/repo-evaluation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ SPDX-License-Identifier: Apache-2.0
> subject kind:
>
> ```bash
> python cli.py repo validate <dir | dir@rev | url@rev> --manifest <path> [--json]
> python cli.py repo fetch <url@rev> # bare, blob-less clone cached per URL
> python cli.py repo validate <dir | dir@rev | url@rev> --manifest <path> [--json] [--out FILE] [--fail-on-warning]
> python cli.py repo fetch <url@rev> [--json] # bare, blob-less clone cached per URL
> ```
>
> Slice S3 adds harness profiles (§6) and the reach probe (§7):
Expand Down Expand Up @@ -45,7 +45,7 @@ SPDX-License-Identifier: Apache-2.0
> Usage:
>
> ```bash
> python cli.py repo budget <source> [--harness claude-code] [--json] [--fail-on-warning]
> python cli.py repo budget <source> [--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.
Expand Down
25 changes: 23 additions & 2 deletions dokimi/dokimi/commands/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,6 +46,8 @@ 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:
_write_json(args.out, report.to_dict())
if args.json:
print(json.dumps(report.to_dict(), indent=2))
else:
Expand All @@ -58,7 +69,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)


Expand Down Expand Up @@ -167,7 +178,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:
Expand Down Expand Up @@ -240,6 +254,8 @@ 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:
_write_json(args.out, payload)
if args.json:
print(json.dumps(payload, indent=2))
else:
Expand Down Expand Up @@ -339,7 +355,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(
Expand Down Expand Up @@ -386,6 +405,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)

Expand Down Expand Up @@ -439,6 +459,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)")
Expand Down
11 changes: 11 additions & 0 deletions dokimi/tests/test_repo_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
42 changes: 42 additions & 0 deletions dokimi/tests/test_repo_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down