Skip to content
Open
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
7 changes: 5 additions & 2 deletions plumb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,13 +484,16 @@ def _run_modify(repo_root: Path, decision_id: str) -> None:
console.print(" [yellow]No staged changes to modify.[/yellow]")
return

# Read spec
# Read spec (resolve directories to individual .md files)
config = load_config(repo_root)
spec_content = ""
if config:
for sp in config.spec_paths:
spec_file = repo_root / sp
if spec_file.is_file():
if spec_file.is_dir():
for md_file in sorted(spec_file.rglob("*.md")):
spec_content += md_file.read_text()
elif spec_file.is_file():
spec_content += spec_file.read_text()

decision_branch = find_decision_branch(repo_root, decision_id)
Expand Down
13 changes: 11 additions & 2 deletions plumb/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,19 @@ def sync_decisions(
)
decisions_text = "\n".join(decision_lines)

# Resolve spec paths: directories expand to all .md files within
# (matches the resolution logic in parse_spec_files above)
resolved_spec_files: list[Path] = []
for spec_path_str in config.spec_paths:
if on_progress:
on_progress(f"Updating spec: {spec_path_str}...")
spec_path = repo_root / spec_path_str
if spec_path.is_dir():
resolved_spec_files.extend(sorted(spec_path.rglob("*.md")))
elif spec_path.is_file():
resolved_spec_files.append(spec_path)

for spec_path in resolved_spec_files:
if on_progress:
on_progress(f"Updating spec: {spec_path.relative_to(repo_root)}...")
if not spec_path.is_file():
continue

Expand Down
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ def initialized_repo(tmp_repo):
return tmp_repo


@pytest.fixture
def initialized_repo_dir_specs(tmp_repo):
"""A tmp_repo with spec_paths pointing to a directory (not individual files)."""
ensure_plumb_dir(tmp_repo)
cfg = PlumbConfig(
spec_paths=["specs/"],
test_paths=["tests/"],
initialized_at=datetime.now(timezone.utc).isoformat(),
)
save_config(tmp_repo, cfg)
# Create specs directory with multiple spec files
specs_dir = tmp_repo / "specs"
specs_dir.mkdir(exist_ok=True)
(specs_dir / "spec.md").write_text("# Spec\n\n## Features\n\nThe system must do X.\n")
(specs_dir / "api.md").write_text("# API\n\n## Endpoints\n\nGET /items returns all items.\n")
# Create tests dir
(tmp_repo / "tests").mkdir(exist_ok=True)
(tmp_repo / ".plumb" / "decisions").mkdir(exist_ok=True)
return tmp_repo


@pytest.fixture
def sample_decisions():
"""Return a list of sample Decision objects."""
Expand Down
48 changes: 48 additions & 0 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,54 @@ def test_new_requirement_gets_current_timestamp(self, initialized_repo):
assert result[0]["last_seen_commit"] is None


class TestSyncDecisionsDirectorySpecPaths:
"""Tests for directory-based spec_paths (e.g., spec_paths: ['specs/'])."""

def test_syncs_when_spec_path_is_directory(self, initialized_repo_dir_specs):
"""sync_decisions should resolve directory spec_paths to .md files."""
d = Decision(
id="dec-dir1",
status="approved",
question="How to authenticate?",
decision="Use API keys.",
created_at=datetime.now(timezone.utc).isoformat(),
)
append_decision(initialized_repo_dir_specs, d, branch="main")

updater_call_count = [0]

def mock_run(fn, *args, **kwargs):
from plumb.programs.spec_updater import WholeFileSpecUpdater
if isinstance(fn, WholeFileSpecUpdater):
updater_call_count[0] += 1
return [{"header": "## Features", "content": "Uses API keys.\n"}], []
return []

with patch("plumb.programs.configure_dspy"), \
patch("plumb.programs.run_with_retries", side_effect=mock_run):
result = sync_decisions(initialized_repo_dir_specs)

# Should have called updater for each .md file in the directory
assert updater_call_count[0] == 2 # spec.md + api.md
assert result["spec_updated"] >= 1

def test_parses_spec_files_from_directory(self, initialized_repo_dir_specs):
"""parse_spec_files should find .md files inside directory spec_paths."""
mock_reqs = [
MagicMock(text="The system must do X.", ambiguous=False),
]

with patch("plumb.programs.configure_dspy"), \
patch("plumb.programs.run_with_retries", return_value=mock_reqs):
result = parse_spec_files(initialized_repo_dir_specs)

# Should parse both spec.md and api.md (2 files × 1 req each = 2, but
# same text → same ID → deduped to 1)
assert len(result) >= 1
req_path = initialized_repo_dir_specs / ".plumb" / "requirements.json"
assert req_path.exists()


class TestSyncDecisionsWholeFile:
"""Tests for the whole-file spec update path."""

Expand Down