diff --git a/README.md b/README.md index 879b79b..30e422d 100644 --- a/README.md +++ b/README.md @@ -131,29 +131,24 @@ Create an isolated integration sandbox for a template: ```bash python -m nightshift.cli integ-run --template tutorial-pastebin -cd integ_runs//project ``` -Activate the generated virtual environment, then install and run the project. +Then run the Python project setup helper. It finds the generated venv, installs this NightShift checkout into it, installs the target project, installs pytest by default, and runs `nightshift validate`: -PowerShell: +```bash +python -m nightshift.cli integ-setup --project integ_runs//project +``` + +After setup, run from the generated project with the venv Python: ```powershell -..\.venv\Scripts\Activate.ps1 -python -m pip install -e ..\..\.. -python -m pip install -e . pytest flask -python -m nightshift.cli validate -python -m nightshift.cli run --task TASK-001 +integ_runs\\.venv\Scripts\python.exe -m nightshift.cli run --task TASK-001 ``` Bash: ```bash -source ../.venv/bin/activate -python -m pip install -e ../../.. -python -m pip install -e . pytest flask -python -m nightshift.cli validate -python -m nightshift.cli run --task TASK-001 +integ_runs//.venv/bin/python -m nightshift.cli run --task TASK-001 ``` Open the read-only artifact dashboard: diff --git a/docs/config-reference.md b/docs/config-reference.md index cef4a38..9ba780e 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -140,35 +140,24 @@ Create a local integration sandbox from the NightShift repository root: python -m nightshift.cli integ-run --template tutorial-pastebin ``` -Then enter the generated project: +Set up the generated Python project: ```bash -cd integ_runs//project +python -m nightshift.cli integ-setup --project integ_runs//project ``` -Activate the sandbox virtual environment and install target dependencies. +The setup helper: -PowerShell: +- finds or creates the integration virtual environment +- installs this NightShift checkout into that venv +- installs the target project with `pip install -e` +- installs extra packages, defaulting to `pytest` +- runs `nightshift validate` unless `--skip-validate` is set -```powershell -..\.venv\Scripts\Activate.ps1 -python -m pip install -e ..\..\.. -python -m pip install -e . pytest flask -``` - -Bash: +Preview commands without running them: ```bash -source ../.venv/bin/activate -python -m pip install -e ../../.. -python -m pip install -e . pytest flask -``` - -Run NightShift inside the generated `project/` directory: - -```bash -python -m nightshift.cli validate -python -m nightshift.cli run --task TASK-001 +python -m nightshift.cli integ-setup --project integ_runs//project --dry-run ``` To clean up old sandboxes before creating a new one, keep only the newest three existing runs: diff --git a/examples/tutorial/03-pastebin/README.md b/examples/tutorial/03-pastebin/README.md index b0be121..618f986 100644 --- a/examples/tutorial/03-pastebin/README.md +++ b/examples/tutorial/03-pastebin/README.md @@ -17,23 +17,12 @@ For an isolated local integration run, use the integration sandbox command from ```bash python -m nightshift.cli integ-run --template tutorial-pastebin -cd integ_runs//project ``` -Activate the generated virtual environment. - -PowerShell: - -```powershell -..\.venv\Scripts\Activate.ps1 -python -m pip install -e ..\..\.. -``` - -Bash: +Then set up the generated Python project: ```bash -source ../.venv/bin/activate -python -m pip install -e ../../.. +python -m nightshift.cli integ-setup --project integ_runs//project ``` The template creates: diff --git a/nightshift/__init__.py b/nightshift/__init__.py index 63648c7..8e7a8cd 100644 --- a/nightshift/__init__.py +++ b/nightshift/__init__.py @@ -1,3 +1,5 @@ """NightShift package.""" -__version__ = "0.1.0" +from .version import __version__, display_version + +__all__ = ["__version__", "display_version"] diff --git a/nightshift/cli.py b/nightshift/cli.py index 9154f57..510bbd9 100644 --- a/nightshift/cli.py +++ b/nightshift/cli.py @@ -10,6 +10,7 @@ from .config import validate_config from .errors import NightShiftError from .init import available_templates, init_project from .integ import create_integration_run +from .integ_setup import format_setup_result, setup_python_project from .pipeline import PipelineRunner from .runlog import RunLogger from .status import build_status, format_status @@ -21,12 +22,13 @@ from .tasks import ( select_task_by_id, validate_task_dependencies, ) +from .version import display_version from .web import create_app def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="nightshift", description="Auditable AI pipeline runner.") - parser.add_argument("--version", action="version", version="nightshift 0.1.0") + parser.add_argument("--version", action="version", version=f"nightshift {display_version()}") subparsers = parser.add_subparsers(dest="command", required=True) @@ -66,6 +68,41 @@ def build_parser() -> argparse.ArgumentParser: ) integ_parser.add_argument("--keep", type=int, help="Keep only the newest N old integration runs before creating a new one.") + setup_parser = subparsers.add_parser( + "integ-setup", + help="Set up a Python integration project venv and dependencies.", + ) + setup_parser.add_argument( + "--project", + default=".", + help="Generated project directory. Defaults to the current directory.", + ) + setup_parser.add_argument( + "--nightshift-root", + help="NightShift checkout to install into the integration venv. Defaults to this checkout.", + ) + setup_parser.add_argument( + "--extra", + action="append", + default=["pytest"], + help="Extra package to install into the venv. May be repeated. Defaults to pytest.", + ) + setup_parser.add_argument( + "--no-create-venv", + action="store_true", + help="Fail instead of creating a missing virtual environment.", + ) + setup_parser.add_argument( + "--skip-validate", + action="store_true", + help="Skip `nightshift validate` after installation.", + ) + setup_parser.add_argument( + "--dry-run", + action="store_true", + help="Print setup commands without running them.", + ) + return parser @@ -138,6 +175,19 @@ def main(argv: list[str] | None = None) -> int: print(f"Integration run: {run.directory}") print(f"Venv: {run.venv_dir}") print(f"Log: {run.log_path}") + print(f"Setup: python -m nightshift.cli integ-setup --project {run.directory / 'project'}") + return 0 + + if args.command == "integ-setup": + result = setup_python_project( + args.project, + nightshift_root=args.nightshift_root, + extras=tuple(args.extra or ()), + create_venv=not args.no_create_venv, + validate=not args.skip_validate, + dry_run=args.dry_run, + ) + print(format_setup_result(result)) return 0 except NightShiftError as exc: diff --git a/nightshift/commands.py b/nightshift/commands.py index f13a71e..bbcb2b9 100644 --- a/nightshift/commands.py +++ b/nightshift/commands.py @@ -7,6 +7,7 @@ import os from pathlib import Path import shlex import subprocess +import sys import time from .artifacts import ArtifactStore @@ -137,11 +138,7 @@ class CommandExecutor: raise CommandError(str(exc)) from exc timeout = timeout_seconds or self.timeout_seconds args: str | list[str] = normalized if shell else shlex.split(normalized) - env = None - if self.safety.allowed_env: - env = {name: os.environ[name] for name in self.safety.allowed_env if name in os.environ} - if "PATH" in os.environ: - env.setdefault("PATH", os.environ["PATH"]) + env = _command_env(self.safety.allowed_env) started = time.monotonic() process = subprocess.Popen( @@ -221,6 +218,18 @@ def _coerce_output(value: str | bytes | None) -> str: return value +def _command_env(allowed_env: tuple[str, ...]) -> dict[str, str]: + env = dict(os.environ) if not allowed_env else { + name: os.environ[name] for name in allowed_env if name in os.environ + } + python_dir = str(Path(sys.executable).resolve().parent) + current_path = env.get("PATH") or os.environ.get("PATH", "") + path_parts = [part for part in current_path.split(os.pathsep) if part] + env["PATH"] = os.pathsep.join([python_dir, *[part for part in path_parts if part != python_dir]]) + env.setdefault("VIRTUAL_ENV", os.environ.get("VIRTUAL_ENV", "")) + return env + + def _kill_process_tree(process: subprocess.Popen[str]) -> None: if os.name == "nt": subprocess.run( diff --git a/nightshift/failures.py b/nightshift/failures.py index 19e2368..a5fa1c5 100644 --- a/nightshift/failures.py +++ b/nightshift/failures.py @@ -35,15 +35,6 @@ def classify_failure(output: str, exit_code: int | None = None, modified_files: lowered = text.lower() failing_tests = extract_failing_tests(text) - if re.search(r"\b(syntaxerror|indentationerror|importerror)\b", text, re.IGNORECASE): - return FailureClassification( - "syntax/import error", - "Python failed while parsing or importing code.", - 0.86, - "Send the failure excerpt and touched files back to the implementer.", - "retry implementation", - failing_tests, - ) missing = re.search(r"No module named ['\"]([^'\"]+)['\"]", text, re.IGNORECASE) if not missing: missing = re.search(r"ModuleNotFoundError:\s*['\"]?([A-Za-z0-9_.-]+)", text, re.IGNORECASE) @@ -57,6 +48,15 @@ def classify_failure(output: str, exit_code: int | None = None, modified_files: "do not retry implementation until dependency is resolved", failing_tests, ) + if re.search(r"\b(syntaxerror|indentationerror|importerror)\b", text, re.IGNORECASE): + return FailureClassification( + "syntax/import error", + "Python failed while parsing or importing code.", + 0.86, + "Send the failure excerpt and touched files back to the implementer.", + "retry implementation", + failing_tests, + ) if any(marker in lowered for marker in ("filenotfounderror", "no such file or directory", "missing fixture", "fixture")): return FailureClassification( "missing resource/fixture", diff --git a/nightshift/integ.py b/nightshift/integ.py index 11ad1ba..5ae9f85 100644 --- a/nightshift/integ.py +++ b/nightshift/integ.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path import shutil +import subprocess import venv from .init import init_project @@ -37,6 +38,7 @@ def create_integration_run(root: Path, *, template: str = "basic", keep: int | N project_dir = run_dir / "project" project_dir.mkdir() init_project(project_dir, template=template) + _initialize_project_git_repo(project_dir) log_path = log_dir / "integ-run.log" log_path.write_text( "\n".join( @@ -55,6 +57,21 @@ def create_integration_run(root: Path, *, template: str = "basic", keep: int | N return IntegrationRun(run_dir, venv_dir, log_path) +def _initialize_project_git_repo(project_dir: Path) -> None: + try: + subprocess.run( + ["git", "init"], + cwd=project_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return + + def cleanup_integration_runs(base: Path, *, keep: int) -> tuple[Path, ...]: if keep < 0: raise ValueError("keep must be zero or greater") diff --git a/nightshift/integ_setup.py b/nightshift/integ_setup.py new file mode 100644 index 0000000..fdf12a1 --- /dev/null +++ b/nightshift/integ_setup.py @@ -0,0 +1,134 @@ +"""Python project setup helper for integration sandboxes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import os +import subprocess +import sys +import venv + +from .errors import NightShiftError + + +@dataclass(frozen=True) +class SetupCommand: + args: tuple[str, ...] + cwd: Path + + +@dataclass(frozen=True) +class IntegrationSetupResult: + project_dir: Path + venv_dir: Path + python: Path + created_venv: bool + commands: tuple[SetupCommand, ...] + dry_run: bool = False + + +def setup_python_project( + project_dir: str | Path = ".", + *, + nightshift_root: str | Path | None = None, + extras: tuple[str, ...] = ("pytest",), + create_venv: bool = True, + validate: bool = True, + dry_run: bool = False, +) -> IntegrationSetupResult: + """Install NightShift and a Python target project into an integration venv.""" + + project = Path(project_dir).resolve() + if not project.exists() or not project.is_dir(): + raise NightShiftError(f"Integration setup error: project directory does not exist: {project}") + + venv_dir, created = _ensure_venv(project, create=create_venv, dry_run=dry_run) + python = _venv_python(venv_dir) + root = Path(nightshift_root).resolve() if nightshift_root else _default_nightshift_root() + if not root.exists(): + raise NightShiftError(f"Integration setup error: NightShift root does not exist: {root}") + + commands = [ + SetupCommand((str(python), "-m", "pip", "install", "-e", str(root)), project), + SetupCommand((str(python), "-m", "pip", "install", "-e", str(project)), project), + ] + if extras: + commands.append(SetupCommand((str(python), "-m", "pip", "install", *extras), project)) + if validate and (project / "nightshift.yaml").exists(): + commands.append(SetupCommand((str(python), "-m", "nightshift.cli", "validate"), project)) + + if not dry_run: + for command in commands: + completed = subprocess.run( + command.args, + cwd=command.cwd, + text=True, + encoding="utf-8", + errors="replace", + ) + if completed.returncode != 0: + rendered = " ".join(command.args) + raise NightShiftError( + f"Integration setup error: command failed with code {completed.returncode}: {rendered}" + ) + + return IntegrationSetupResult( + project_dir=project, + venv_dir=venv_dir, + python=python, + created_venv=created, + commands=tuple(commands), + dry_run=dry_run, + ) + + +def format_setup_result(result: IntegrationSetupResult) -> str: + lines = [ + f"Project: {result.project_dir}", + f"Venv: {result.venv_dir}", + f"Python: {result.python}", + f"Created venv: {str(result.created_venv).lower()}", + ] + if result.dry_run: + lines.append("Dry run: true") + lines.append("Commands:") + for command in result.commands: + lines.append(f"- ({command.cwd}) {' '.join(command.args)}") + else: + lines.append("Setup complete.") + lines.append("Run from the project directory:") + lines.append(f" {result.python} -m nightshift.cli run --task TASK-001") + return "\n".join(lines) + + +def _ensure_venv(project: Path, *, create: bool, dry_run: bool) -> tuple[Path, bool]: + candidates = _venv_candidates(project) + for candidate in candidates: + if _venv_python(candidate).exists(): + return candidate, False + if not create: + raise NightShiftError( + "Integration setup error: no virtual environment found. " + f"Checked: {', '.join(str(path) for path in candidates)}" + ) + target = candidates[0] + if not dry_run: + venv.EnvBuilder(with_pip=True).create(target) + return target, True + + +def _venv_candidates(project: Path) -> tuple[Path, ...]: + if project.name == "project" and project.parent.name: + return (project.parent / ".venv", project / ".venv") + return (project / ".venv", project.parent / ".venv") + + +def _venv_python(venv_dir: Path) -> Path: + if os.name == "nt": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def _default_nightshift_root() -> Path: + return Path(__file__).resolve().parents[1] diff --git a/nightshift/patches.py b/nightshift/patches.py index ac21886..d96104d 100644 --- a/nightshift/patches.py +++ b/nightshift/patches.py @@ -119,12 +119,14 @@ def generate_patch_from_file_updates( root = resolve_project_root(project_root) scoped_roots = validate_scoped_paths(root, safety.scoped_paths or (".",)) patch_parts: list[str] = [] - seen: set[str] = set() + seen: dict[str, str] = {} for update in updates: normalized_path = _normalize_update_path(update.path) if normalized_path in seen: + if seen[normalized_path] == update.content: + continue raise PipelineError(f"File writer error: duplicate file block `{normalized_path}`.") - seen.add(normalized_path) + seen[normalized_path] = update.content _validate_patch_path(normalized_path, root, scoped_roots, forbidden_paths) file_path = resolve_inside_root(root, normalized_path, f"file update '{normalized_path}'") old_text = file_path.read_text(encoding="utf-8", errors="replace") if file_path.exists() else "" diff --git a/nightshift/pipeline.py b/nightshift/pipeline.py index 9b48b36..c502e04 100644 --- a/nightshift/pipeline.py +++ b/nightshift/pipeline.py @@ -1181,16 +1181,23 @@ class PipelineRunner: def _build_context_pack(self, task: Task) -> str: terms = _task_search_terms(task) - files = self.repo_tools.list_files(".", pattern="*.py", max_files=80) + lookup_paths = self.config.safety.scoped_paths or (".",) + files = self._list_context_files(lookup_paths) grep_sections: list[str] = [] for term in terms[:5]: + scoped_results = [] + for path in lookup_paths: + scoped_results.append( + f"#### Path: {path}\n\n" + "```text\n" + f"{self.repo_tools.grep(re.escape(term), path, max_matches=20).rstrip()}\n" + "```" + ) grep_sections.extend( [ f"### Search: {term}", "", - "```text", - self.repo_tools.grep(re.escape(term), ".", max_matches=20), - "```", + *scoped_results, "", ] ) @@ -1223,6 +1230,18 @@ class PipelineRunner: ] ) + def _list_context_files(self, paths: tuple[str, ...]) -> str: + sections: list[str] = [] + for path in paths: + sections.extend( + [ + f"## Path: {path}", + self.repo_tools.list_files(path, pattern="*", max_files=80).rstrip(), + "", + ] + ) + return "\n".join(sections).strip() or "No files found." + def _read_output(self, output_path: str | None) -> str: if output_path is None: return "" diff --git a/nightshift/project_templates/tutorial-pastebin/README.md b/nightshift/project_templates/tutorial-pastebin/README.md index 42b7e43..1d8ea42 100644 --- a/nightshift/project_templates/tutorial-pastebin/README.md +++ b/nightshift/project_templates/tutorial-pastebin/README.md @@ -12,26 +12,15 @@ Or create an isolated integration sandbox from the NightShift repository root: ```bash python -m nightshift.cli integ-run --template tutorial-pastebin -cd integ_runs//project ``` -Activate the generated virtual environment. - -PowerShell: - -```powershell -..\.venv\Scripts\Activate.ps1 -python -m pip install -e ..\..\.. -``` - -Bash: +Then set up the generated Python project: ```bash -source ../.venv/bin/activate -python -m pip install -e ../../.. +python -m nightshift.cli integ-setup --project integ_runs//project ``` -Install target dependencies: +For a normal non-integration checkout, install target dependencies: ```bash python -m pip install -e . pytest flask diff --git a/nightshift/terminal.py b/nightshift/terminal.py index e1baf6c..7b6e202 100644 --- a/nightshift/terminal.py +++ b/nightshift/terminal.py @@ -7,6 +7,7 @@ import sys from typing import TextIO import random +from .version import display_version RESET = "\x1b[0m" @@ -77,7 +78,7 @@ def format_banner(stream: TextIO | None = None) -> str: f" [ {quote} ]", " [ planner | implementer | verifier | audit ]", "", - " VERSION: 0.1.0-alpha-glizzy", + f" VERSION: {display_version()}", "-" * 50, "", ] diff --git a/nightshift/version.py b/nightshift/version.py new file mode 100644 index 0000000..034b45b --- /dev/null +++ b/nightshift/version.py @@ -0,0 +1,46 @@ +"""NightShift version metadata.""" + +from __future__ import annotations + + +PACKAGE_VERSION = "0.2.2" +RELEASE_CHANNEL = "alpha" +hotdog_version = "footlong" +topping_version = "mustard" + +HOTDOG_VERSIONS = ( + "bratwurst", + "italian-sausage", + "footlong", + "new-york", + "chicago", + "coney", + "corn-dog", + "kielbasa", + "vienna", + "andouille", + "chorizo", + "frankfurter", +) + +TOPPING_VERSIONS = ( + "relish", + "mustard", + "mayo", + "onions", + "sauerkraut", + "jalapenos", + "pickles", + "chili", + "cheese", + "sport-peppers", + "ketchup", + "slaw", +) + + +def display_version() -> str: + return f"{PACKAGE_VERSION}-{RELEASE_CHANNEL}-{hotdog_version}-{topping_version}" + + +__version__ = PACKAGE_VERSION diff --git a/tests/test_commands.py b/tests/test_commands.py index b9a2f43..672dcb8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5,8 +5,10 @@ import unittest from nightshift.artifacts import ArtifactStore from nightshift.commands import CommandExecutor from nightshift.commands import CommandRun, format_command_runs +from nightshift.commands import _command_env from nightshift.config import SafetyConfig, StageConfig from nightshift.errors import CommandError +import sys PASSING_COMMAND = 'python -c "print(\'ok\')"' @@ -168,6 +170,12 @@ class CommandExecutorTests(unittest.TestCase): self.assertIn("Command: `cmd`", output) self.assertIn("### stderr", output) + def test_command_env_prefers_current_python_directory(self) -> None: + env = _command_env(()) + + first_path = env["PATH"].split(";")[0] if ";" in env["PATH"] else env["PATH"].split(":")[0] + self.assertEqual(Path(first_path), Path(sys.executable).resolve().parent) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_integ_setup.py b/tests/test_integ_setup.py new file mode 100644 index 0000000..233913b --- /dev/null +++ b/tests/test_integ_setup.py @@ -0,0 +1,49 @@ +from pathlib import Path +import tempfile +import unittest + +from nightshift.integ import create_integration_run +from nightshift.integ_setup import format_setup_result, setup_python_project + + +class IntegrationSetupTests(unittest.TestCase): + def test_setup_python_project_dry_run_uses_integration_venv(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + run = create_integration_run(root, template="tutorial-pastebin") + + result = setup_python_project( + run.directory / "project", + nightshift_root=Path(__file__).resolve().parents[1], + extras=("pytest", "flask"), + dry_run=True, + ) + + self.assertEqual(result.venv_dir, run.venv_dir) + self.assertFalse(result.created_venv) + rendered = format_setup_result(result) + self.assertIn("pip install -e", rendered) + self.assertIn("pytest", rendered) + self.assertIn("flask", rendered) + self.assertTrue(any("nightshift.cli validate" in " ".join(command.args) for command in result.commands)) + self.assertTrue((run.directory / "project" / ".git").exists()) + + def test_setup_python_project_dry_run_creates_project_local_venv_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + project = Path(directory) / "project" + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") + + result = setup_python_project( + project, + nightshift_root=Path(__file__).resolve().parents[1], + extras=(), + dry_run=True, + ) + + self.assertEqual(result.venv_dir, project.parent / ".venv") + self.assertTrue(result.created_venv) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_patches.py b/tests/test_patches.py index 541987a..c64cdb2 100644 --- a/tests/test_patches.py +++ b/tests/test_patches.py @@ -254,6 +254,30 @@ two with self.assertRaisesRegex(PipelineError, "duplicate file block"): generate_patch_from_file_updates(updates, root, safety) + def test_file_updates_allow_identical_duplicate_blocks(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "app.py").write_text("old\n", encoding="utf-8") + safety = SafetyConfig( + require_clean_worktree=False, + scoped_paths=(".",), + allowed_commands=(), + forbidden_commands=(), + ) + updates = parse_file_updates( + """```file:app.py +new +``` +```file:app.py +new +``` +""" + ) + + patch = generate_patch_from_file_updates(updates, root, safety) + + self.assertEqual(patch.count("diff --git a/app.py b/app.py"), 1) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9649b6d..113c703 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -308,6 +308,38 @@ Acceptance Criteria: self.assertIn("Context Pack", pack.read_text(encoding="utf-8")) self.assertIn("app.py", pack.read_text(encoding="utf-8")) + def test_repo_context_stage_respects_scoped_paths_without_project_root(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_common_files(root) + (root / "src").mkdir() + (root / "tests").mkdir() + (root / "src" / "app.py").write_text("def create_snippet():\n return True\n", encoding="utf-8") + (root / "tests" / "test_app.py").write_text("def test_create_snippet():\n assert True\n", encoding="utf-8") + stages = (StageConfig(id="context", type="repo_context", output="context-pack.md"),) + config = make_config(root, stages) + config = replace( + config, + safety=SafetyConfig( + require_clean_worktree=False, + scoped_paths=("src", "tests", "pyproject.toml", "README.md"), + allowed_commands=config.safety.allowed_commands, + forbidden_commands=config.safety.forbidden_commands, + ), + ) + (root / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") + (root / "README.md").write_text("# Demo\n", encoding="utf-8") + runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run")) + task = parse_tasks(TASK_MD)[0] + + result = runner.run_task(task) + + pack = root / ".nightshift" / "runs" / "test-run" / "tasks" / task.id / "context-pack.md" + self.assertEqual(result.status, "complete") + content = pack.read_text(encoding="utf-8") + self.assertIn("src/app.py", content) + self.assertIn("tests/test_app.py", content) + def test_project_context_chart_is_written_during_run(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_reliability_features.py b/tests/test_reliability_features.py index 073e834..903dd98 100644 --- a/tests/test_reliability_features.py +++ b/tests/test_reliability_features.py @@ -22,6 +22,20 @@ class ReliabilityFeatureTests(unittest.TestCase): self.assertIn("flask", result.probable_root_cause) self.assertIn("do not retry", result.retry_recommendation) + def test_failure_classifier_prioritizes_module_not_found_in_pytest_import_error(self) -> None: + result = classify_failure( + "\n".join( + [ + "ImportError while importing test module 'tests/test_app.py'.", + "ModuleNotFoundError: No module named 'pastebin_app'", + ] + ), + exit_code=2, + ) + + self.assertEqual(result.category, "missing dependency") + self.assertIn("pastebin_app", result.probable_root_cause) + def test_command_failure_writes_diagnostics_and_retry_memory(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..7c59a18 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,40 @@ +import subprocess +import sys +import unittest + +from nightshift.terminal import format_banner +from nightshift.version import ( + HOTDOG_VERSIONS, + PACKAGE_VERSION, + TOPPING_VERSIONS, + display_version, + hotdog_version, + topping_version, +) + + +class VersionTests(unittest.TestCase): + def test_display_version_includes_channel_hotdog_and_topping(self) -> None: + self.assertEqual(display_version(), "0.2.2-alpha-footlong-mustard") + self.assertEqual(PACKAGE_VERSION, "0.2.2") + self.assertIn(hotdog_version, HOTDOG_VERSIONS) + self.assertIn(topping_version, TOPPING_VERSIONS) + + def test_banner_uses_central_display_version(self) -> None: + self.assertIn(f"VERSION: {display_version()}", format_banner()) + + def test_cli_version_uses_central_display_version(self) -> None: + completed = subprocess.run( + [sys.executable, "-m", "nightshift.cli", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=True, + ) + + self.assertEqual(completed.stdout.strip(), f"nightshift {display_version()}") + + +if __name__ == "__main__": + unittest.main()