Skip to content

Commit 07e8791

Browse files
Make loopforge run accept a directory, like lint
Dogfooding found that 'loopforge lint .' accepts a directory (auto-finds loop.toml) but 'loopforge run .' crashed with 'Is a directory' — run read the path as a file and computed the loop root from it. A new resolve_loop_file() resolves a directory to its single loop.toml (clear error if zero or more than one), shared by parse_loop and run, so run and lint take the same paths.
1 parent fcf50a4 commit 07e8791

4 files changed

Lines changed: 43 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 0.5.2
4+
5+
- **`run` accepts a directory** like `lint` does — `loopforge run mydir/` resolves the single
6+
`loop.toml` inside instead of crashing with "Is a directory". Found by dogfooding (lint a dir,
7+
then run it). Multiple loops in one dir → a clear "name the one to run" error.
8+
39
## 0.5.1
410

511
- **`eval` gate fix**`--min-accuracy` no longer fails when no predictions are resolved yet.

loopforge/models.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,27 @@ def parse_text(text: str, path: Path | None = None) -> Loop:
118118
return loop
119119

120120

121-
def parse_loop(path: str | Path) -> Loop:
122-
"""Read and parse a loop.toml file. Raises LoopError naming the file on any failure."""
121+
def resolve_loop_file(path: str | Path) -> Path:
122+
"""Resolve a path to a single loop file. A file is taken as-is; a directory is searched for
123+
`loop.toml` / `*.loop.toml` so `run <dir>` works like `lint <dir>`. Raises LoopError if nothing
124+
is found, or if a directory holds more than one loop (ambiguous — name the file)."""
123125
p = Path(path)
126+
if p.is_file():
127+
return p
128+
if p.is_dir():
129+
found = sorted({*p.rglob("loop.toml"), *p.rglob("*.loop.toml")}, key=str)
130+
if not found:
131+
raise LoopError(f"no loop definitions (loop.toml / *.loop.toml) found under {p}")
132+
if len(found) > 1:
133+
names = ", ".join(str(f.relative_to(p)) for f in found)
134+
raise LoopError(f"{p} holds multiple loops ({names}) — name the one to run")
135+
return found[0]
136+
raise LoopError(f"no such loop file: {p}")
137+
138+
139+
def parse_loop(path: str | Path) -> Loop:
140+
"""Read and parse a loop file, or the single loop under a directory. Raises LoopError on failure."""
141+
p = resolve_loop_file(path)
124142
try:
125143
text = p.read_text(encoding="utf-8")
126144
except FileNotFoundError as exc:

loopforge/runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from pathlib import Path
1919

2020
from .linter import lint_text
21-
from .models import Loop, LoopError, Severity, parse_loop
21+
from .models import Loop, LoopError, Severity, parse_loop, resolve_loop_file
2222

2323

2424
@dataclass
@@ -179,7 +179,7 @@ def run(
179179
max_iterations: int | None = None,
180180
) -> RunResult:
181181
"""Execute a loop. Refuses to start a loop with a CRITICAL completeness finding (no brake)."""
182-
path = Path(source)
182+
path = resolve_loop_file(source) # a directory resolves to its single loop.toml, like `lint`
183183
loop = parse_loop(path)
184184
root = path.parent
185185

tests/test_runner.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ def test_dry_run_does_not_execute(tmp_path):
3333
assert result.iterations == 0
3434

3535

36+
def test_run_accepts_a_directory_like_lint(tmp_path):
37+
# `loopforge run <dir>` must work like `lint <dir>` — resolve the single loop.toml inside.
38+
make_loop(tmp_path, act="echo hi") # writes <tmp>/runme/loop.toml
39+
result = run(tmp_path / "runme", dry_run=True)
40+
assert result.loop == "runme" and result.outcome == "dry-run"
41+
42+
43+
def test_run_directory_with_multiple_loops_is_ambiguous(tmp_path):
44+
make_loop(tmp_path, act="echo a") # <tmp>/runme/loop.toml
45+
(tmp_path / "other.loop.toml").write_text(
46+
(tmp_path / "runme" / "loop.toml").read_text(), encoding="utf-8")
47+
with pytest.raises(LoopError, match="multiple loops"):
48+
run(tmp_path, dry_run=True)
49+
50+
3651
def test_plan_lists_all_blocks(tmp_path):
3752
p = make_loop(tmp_path, act="echo hi")
3853
text = plan(parse_loop(p))

0 commit comments

Comments
 (0)