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
5 changes: 4 additions & 1 deletion src/socrates120x/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,10 @@ def _cmd_companyos(args: argparse.Namespace) -> int:
target: Path = args.path.expanduser().resolve()
try:
written = scaffold_companyos(target)
except FileExistsError as e:
except (FileExistsError, NotADirectoryError) as e:
# scaffold_companyos can now raise NotADirectoryError when the
# target exists as a regular file. Catch it here so the CLI
# surfaces a clean error instead of a Python stacktrace.
print(f"error: {e}", file=sys.stderr)
return 2
print(f"Scaffolded CompanyOS at: {target}")
Expand Down
13 changes: 12 additions & 1 deletion src/socrates120x/companyos.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,18 @@


def scaffold_companyos(target: Path, *, overwrite: bool = False) -> list[Path]:
"""Create the CompanyOS macro layer at *target*. Returns files written."""
"""Create the CompanyOS macro layer at *target*. Returns files written.

Raises NotADirectoryError if *target* is an existing regular file —
previously `any(target.iterdir())` cascaded into NotADirectoryError
from inside the boolean short-circuit, with a confusing traceback.
Reject explicitly with an actionable message.
"""
if target.exists() and target.is_file():
raise NotADirectoryError(
f"Cannot scaffold CompanyOS into a regular file: {target}. "
f"Pass a directory path (it will be created if missing)."
)
if target.exists() and not overwrite and any(target.iterdir()):
raise FileExistsError(
f"Refusing to scaffold CompanyOS into non-empty path: {target}"
Expand Down
9 changes: 9 additions & 0 deletions src/socrates120x/scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ def scaffold(target: Path, *, overwrite: bool = False) -> list[Path]:

Returns the list of files created (or that already existed).
Raises FileExistsError if *target* already exists and ``overwrite`` is False.
Raises NotADirectoryError if *target* already exists as a regular file —
previously the call cascaded into a confusing "Cannot create directory"
error from inside the FILES/DIRS loop. Catch it up-front so the operator
gets an actionable message before any side effects.
"""
if target.exists() and target.is_file():
raise NotADirectoryError(
f"Cannot scaffold into a regular file: {target}. "
f"Pass a directory path (it will be created if missing)."
)
if target.exists() and not overwrite:
raise FileExistsError(f"Refusing to overwrite existing path: {target}")

Expand Down
30 changes: 30 additions & 0 deletions tests/test_companyos.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,33 @@ def test_companyos_allows_empty_dir(tmp_path: Path) -> None:
# Should succeed against an empty pre-existing dir.
scaffold_companyos(target)
assert (target / "AGENTS.md").is_file()


def test_companyos_rejects_file_target(tmp_path) -> None:
"""Symmetric guard with scaffold(): passing a regular file path must
fail up-front, not midway through the per-file write loop."""
import pytest

from socrates120x.companyos import scaffold_companyos

file_path = tmp_path / "co.txt"
file_path.write_text("operator's notes", encoding="utf-8")
with pytest.raises(NotADirectoryError, match="regular file"):
scaffold_companyos(file_path)
assert file_path.read_text(encoding="utf-8") == "operator's notes"


def test_cli_companyos_handles_file_target_gracefully(tmp_path, capsys) -> None:
"""CLI entry point must catch NotADirectoryError too, not just FileExistsError.
Pre-fix the new validation in scaffold_companyos crashed the CLI with a
stacktrace because _cmd_companyos only caught FileExistsError."""
from socrates120x.cli import main

file_path = tmp_path / "operators-notes.txt"
file_path.write_text("user content", encoding="utf-8")
rc = main(["companyos", str(file_path)])
assert rc == 2
err = capsys.readouterr().err
assert "error:" in err
# The actual file content is untouched.
assert file_path.read_text(encoding="utf-8") == "user content"
37 changes: 37 additions & 0 deletions tests/test_scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,40 @@ def test_scaffold_overwrite_flag_allows_reentry(tmp_path: Path) -> None:
scaffold(target, overwrite=True)
for f in FILES:
assert (target / f).is_file()


# ---------------------------------------------------------------------------
# Target-is-a-file rejection
# (validation/scaffold-rejects-file-target)
# ---------------------------------------------------------------------------


def test_scaffold_rejects_file_target_with_clear_message(tmp_path) -> None:
"""If the operator passes a path that is an existing regular file,
fail up-front with NotADirectoryError, not a confusing mid-scaffold
error from inside the directory-creation loop."""
import pytest

from socrates120x.scaffold import scaffold

file_path = tmp_path / "not-a-dir.txt"
file_path.write_text("hi", encoding="utf-8")
with pytest.raises(NotADirectoryError, match="regular file"):
scaffold(file_path)
# And nothing got created next to it.
assert not (file_path.parent / "not-a-dir.txt" / "planning").exists()


def test_scaffold_overwrite_true_still_rejects_file_target(tmp_path) -> None:
"""overwrite=True is for replacing an EMPTY directory; it must NOT
silently overwrite a regular file (which would be data loss)."""
import pytest

from socrates120x.scaffold import scaffold

file_path = tmp_path / "important.txt"
file_path.write_text("user content I would hate to lose", encoding="utf-8")
with pytest.raises(NotADirectoryError):
scaffold(file_path, overwrite=True)
# File must be untouched.
assert file_path.read_text(encoding="utf-8") == "user content I would hate to lose"
Loading