"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime.""" from __future__ import annotations import json import os import time from pathlib import Path import pytest from tests.conftest import requires_bash from tests.extensions.test_extension_agent_context import ( BASH, POWERSHELL, _bash_posix_path, _run_bash_agent_context_script, _run_powershell_agent_context_script, ) def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None: """Write agent-context extension config as JSON. JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in ConvertFrom-Json can parse it without needing powershell-yaml or Python. Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json fallback actually works on Windows CI. """ cfg_dir = root / ".specify" / "extensions" / "agent-context" cfg_dir.mkdir(parents=True, exist_ok=True) (cfg_dir / "agent-context-config.yml").write_text( json.dumps({ "context_file": context_file, "context_markers": { "start": "", "end": "", }, }), encoding="utf-8", ) def _write_feature_json(root: Path, feature_directory: str) -> None: specify_dir = root / ".specify" specify_dir.mkdir(parents=True, exist_ok=True) (specify_dir / "feature.json").write_text( json.dumps({"feature_directory": feature_directory}), encoding="utf-8", ) def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path: p = root / feature_dir / "plan.md" p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content, encoding="utf-8") return p @requires_bash def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: """feature.json points to the active feature; that plan.md is injected.""" _setup_project(tmp_path) _make_plan(tmp_path, "specs/001-active") _write_feature_json(tmp_path, "specs/001-active") result = _run_bash_agent_context_script(tmp_path) assert result.returncode == 0, result.stderr + result.stdout ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") assert "specs/001-active/plan.md" in ctx @requires_bash def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: """An older spec's plan.md modified more recently must NOT win over feature.json.""" _setup_project(tmp_path) active = _make_plan(tmp_path, "specs/001-active") stale = _make_plan(tmp_path, "specs/000-stale") now = time.time() os.utime(active, (now - 10, now - 10)) os.utime(stale, (now, now)) _write_feature_json(tmp_path, "specs/001-active") result = _run_bash_agent_context_script(tmp_path) assert result.returncode == 0, result.stderr + result.stdout ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") assert "specs/001-active/plan.md" in ctx assert "specs/000-stale/plan.md" not in ctx @requires_bash def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None: """No feature.json → mtime fallback selects the most recently modified plan.""" _setup_project(tmp_path) old = _make_plan(tmp_path, "specs/000-old") newer = _make_plan(tmp_path, "specs/001-newer") now = time.time() os.utime(old, (now - 10, now - 10)) os.utime(newer, (now, now)) result = _run_bash_agent_context_script(tmp_path) assert result.returncode == 0, result.stderr + result.stdout ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") assert "specs/001-newer/plan.md" in ctx @requires_bash def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None: """feature.json exists but plan.md not yet written → fall back to mtime.""" _setup_project(tmp_path) _make_plan(tmp_path, "specs/000-old") _write_feature_json(tmp_path, "specs/001-new") result = _run_bash_agent_context_script(tmp_path) assert result.returncode == 0, result.stderr + result.stdout ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") assert "specs/000-old/plan.md" in ctx @requires_bash def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: """Absolute feature_directory under PROJECT_ROOT → project-relative path in context.""" _setup_project(tmp_path) active = _make_plan(tmp_path, "specs/001-active") stale = _make_plan(tmp_path, "specs/000-stale") now = time.time() os.utime(active, (now - 10, now - 10)) os.utime(stale, (now, now)) # Write POSIX absolute path — mtime would pick 000-stale without feature.json _write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active")) result = _run_bash_agent_context_script(tmp_path) assert result.returncode == 0, result.stderr + result.stdout ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") assert "specs/001-active/plan.md" in ctx assert "specs/000-stale/plan.md" not in ctx assert _bash_posix_path(tmp_path) not in ctx @requires_bash def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: """Absolute feature_directory outside PROJECT_ROOT → absolute path preserved in context.""" project = tmp_path / "project" external = tmp_path / "external" / "001-feature" project.mkdir() external.mkdir(parents=True) (external / "plan.md").write_text("# plan\n", encoding="utf-8") _setup_project(project) _write_feature_json(project, _bash_posix_path(external)) result = _run_bash_agent_context_script(project) assert result.returncode == 0, result.stderr + result.stdout ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") assert _bash_posix_path(external) + "/plan.md" in ctx @pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: """PowerShell: absolute feature_directory under project root is normalized to relative path.""" _setup_project(tmp_path) active = _make_plan(tmp_path, "specs/001-active") stale = _make_plan(tmp_path, "specs/000-stale") now = time.time() os.utime(active, (now - 10, now - 10)) os.utime(stale, (now, now)) # Native str() — PowerShell expects Windows-native paths, not MSYS2 /c/... form _write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active")) result = _run_powershell_agent_context_script(tmp_path) assert result.returncode == 0, result.stderr + result.stdout ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") assert "at specs/001-active/plan.md" in ctx assert "specs/000-stale/plan.md" not in ctx assert tmp_path.resolve().as_posix() not in ctx @pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: """PowerShell: stale plan touched more recently must not win over feature.json.""" _setup_project(tmp_path) active = _make_plan(tmp_path, "specs/001-active") stale = _make_plan(tmp_path, "specs/000-stale") now = time.time() os.utime(active, (now - 10, now - 10)) os.utime(stale, (now, now)) _write_feature_json(tmp_path, "specs/001-active") result = _run_powershell_agent_context_script(tmp_path) assert result.returncode == 0, result.stderr + result.stdout ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") assert "specs/001-active/plan.md" in ctx assert "specs/000-stale/plan.md" not in ctx @pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: """PowerShell: absolute feature_directory outside project root → absolute path preserved.""" project = tmp_path / "project" external = tmp_path / "external" / "001-feature" project.mkdir() external.mkdir(parents=True) (external / "plan.md").write_text("# plan\n", encoding="utf-8") _setup_project(project) _write_feature_json(project, str(external)) result = _run_powershell_agent_context_script(project) assert result.returncode == 0, result.stderr + result.stdout ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") assert external.resolve().as_posix() + "/plan.md" in ctx