From 295eb221e37e490303b32ab758287864e1c7cea1 Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:46:29 +0200 Subject: [PATCH] feat(extensions): port update-agent-context to Python (#3387) * feat(extensions): port update-agent-context to Python Ports the agent-context extension updater to a single Python script, per #3281 and the check-prerequisites PoC pattern from #3302. The bash version already ran its core logic through embedded Python heredocs, so the port lifts that logic into a standalone script. Parity tests run bash and Python side by side and compare output and resulting context-file bytes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions): match bash case-insensitivity on MSYS, test unparseable config gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/python/update_agent_context.py | 352 +++++++++++++ ...test_update_agent_context_python_parity.py | 481 ++++++++++++++++++ 2 files changed, 833 insertions(+) create mode 100644 extensions/agent-context/scripts/python/update_agent_context.py create mode 100644 tests/extensions/test_update_agent_context_python_parity.py diff --git a/extensions/agent-context/scripts/python/update_agent_context.py b/extensions/agent-context/scripts/python/update_agent_context.py new file mode 100644 index 000000000..15c0dceef --- /dev/null +++ b/extensions/agent-context/scripts/python/update_agent_context.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Refresh the managed Spec Kit section in the coding agent's context file(s). + +Python port of ``update-agent-context.sh`` / ``update-agent-context.ps1``. + +Reads ``context_files`` or ``context_file``, plus ``context_markers.{start,end}``, +from the agent-context extension config: + .specify/extensions/agent-context/agent-context-config.yml + +Usage: update_agent_context.py [plan_path] + +When ``plan_path`` is omitted, the script derives it from +``.specify/feature.json`` (written by /speckit-specify). Falls back to the most +recently modified ``specs/*/plan.md`` only when feature.json is absent or its +plan does not exist yet. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +DEFAULT_START = "" +DEFAULT_END = "" + + +def _err(message: str) -> None: + print(message, file=sys.stderr) + + +def _get_str(obj: object, *keys: str) -> str: + node = obj + for key in keys: + if isinstance(node, dict) and key in node: + node = node[key] + else: + return "" + return node if isinstance(node, str) else "" + + +def _collect_context_files(data: dict, project_root: str) -> list[str]: + """Resolve the managed context files from config, mirroring the bash logic.""" + context_files: list[str] = [] + seen: set[str] = set() + case_insensitive = sys.platform.startswith(("win32", "cygwin", "msys")) + + def add(value: object) -> None: + if not isinstance(value, str): + return + candidate = value.strip() + if not candidate: + return + key = candidate.casefold() if case_insensitive else candidate + if key in seen: + return + context_files.append(candidate) + seen.add(key) + + raw_files = data.get("context_files") + if isinstance(raw_files, list): + for value in raw_files: + add(value) + if not context_files: + add(_get_str(data, "context_file")) + if not context_files: + # Self-seed: when the config declares no target, derive one from the + # active integration recorded in init-options.json, mapped through the + # bundled agent-context-defaults.json file. Independent of the Specify + # CLI by design. + integration_key = "" + try: + with open( + f"{project_root}/.specify/init-options.json", "r", encoding="utf-8" + ) as fh: + opts = json.load(fh) + if isinstance(opts, dict): + value = opts.get("integration") or opts.get("ai") or "" + integration_key = value if isinstance(value, str) else "" + except Exception: + integration_key = "" + if integration_key: + defaults_path = ( + f"{project_root}/.specify/extensions/agent-context/" + "agent-context-defaults.json" + ) + mapping = {} + try: + with open(defaults_path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {} + mapping = agents if isinstance(agents, dict) else {} + except Exception: + _err( + "agent-context: unable to read %s; cannot self-seed the context " + "file. Set context_file in the extension config." % defaults_path + ) + mapping = {} + add(mapping.get(integration_key, "") or "") + if not context_files: + _err( + "agent-context: no default context file is known for integration " + "%s. Set context_file in the extension config to choose one." + % integration_key + ) + return context_files + + +def _validate_context_file(project_root: str, context_file: str) -> str | None: + """Return an error message when the path escapes the project root.""" + if context_file.startswith("/") or re.match(r"^[A-Za-z]:", context_file): + return ( + "agent-context: context files must be project-relative paths; " + f"got '{context_file}'." + ) + if "\\" in context_file: + return ( + "agent-context: context files must not contain backslash separators; " + f"got '{context_file}'." + ) + if ".." in context_file.split("/"): + return ( + "agent-context: context files must not contain '..' path segments; " + f"got '{context_file}'." + ) + root = Path(project_root).resolve() + target = (root / context_file).resolve() + try: + target.relative_to(root) + except ValueError: + return ( + "agent-context: context file path resolves outside the project root; " + f"got '{context_file}'." + ) + return None + + +def _resolve_plan_path(project_root: str) -> str: + """Derive the plan path: feature.json first, then the mtime fallback.""" + plan_path = "" + feature_json = Path(project_root) / ".specify" / "feature.json" + if feature_json.is_file(): + feature_dir = "" + try: + with open(feature_json, "r", encoding="utf-8") as fh: + data = json.load(fh) + value = data.get("feature_directory", "") + feature_dir = value if isinstance(value, str) else "" + except Exception: + feature_dir = "" + # Normalize backslashes (written by PS on Windows) before path ops. + feature_dir = feature_dir.replace("\\", "/").rstrip("/") + if feature_dir: + # feature_directory may be relative or absolute (absolute paths + # outside the project root are preserved as-is), including + # drive-qualified paths (C:/...) written by PowerShell on Windows. + if feature_dir.startswith("/") or re.match(r"^[A-Za-z]:/", feature_dir): + candidate = Path(feature_dir) / "plan.md" + else: + candidate = Path(project_root) / feature_dir / "plan.md" + if candidate.is_file(): + # Resolve symlinks before comparing so paths like /var/… vs + # /private/var/… (macOS) are treated as equivalent. + root = Path(project_root).resolve() + resolved = candidate.resolve() + try: + plan_path = resolved.relative_to(root).as_posix() + except ValueError: + plan_path = resolved.as_posix() + + if not plan_path: + root = Path(project_root).resolve() + plans = sorted( + (root / "specs").glob("*/plan.md"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if plans: + try: + plan_path = plans[0].relative_to(root).as_posix() + except ValueError: + plan_path = "" + return plan_path + + +def _build_section(marker_start: str, marker_end: str, plan_path: str) -> str: + lines = [ + marker_start, + "For additional context about technologies to be used, project structure,", + "shell commands, and other important information, read the current plan", + ] + if plan_path: + lines.append(f"at {plan_path}") + lines.append(marker_end) + return "\n".join(lines) + "\n" + + +def ensure_mdc_frontmatter(content: str) -> str: + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with + ``alwaysApply: true``. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + """ + leading_ws = len(content) - len(content.lstrip()) + leading = content[:leading_ws] + stripped = content[leading_ws:] + + if not stripped.startswith("---"): + return "---\nalwaysApply: true\n---\n\n" + content + + match = re.match( + r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", + stripped, + re.DOTALL, + ) + if not match: + return "---\nalwaysApply: true\n---\n\n" + content + + opening, fm_text, closing, sep, rest = match.groups() + newline = "\r\n" if "\r\n" in opening else "\n" + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text): + return content + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): + fm_text = re.sub( + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", + r"\1alwaysApply: true\2", + fm_text, + count=1, + ) + elif fm_text.strip(): + fm_text = fm_text + newline + "alwaysApply: true" + else: + fm_text = "alwaysApply: true" + + return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" + + +def _upsert_section( + ctx_path: str, marker_start: str, marker_end: str, section: str +) -> None: + """Insert or replace the managed section, then normalize and write.""" + if os.path.exists(ctx_path): + with open(ctx_path, "r", encoding="utf-8-sig") as fh: + content = fh.read() + s = content.find(marker_start) + e = content.find(marker_end, s if s != -1 else 0) + if s != -1 and e != -1 and e > s: + end_of_marker = e + len(marker_end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:s] + section + content[end_of_marker:] + elif s != -1: + new_content = content[:s] + section + elif e != -1: + end_of_marker = e + len(marker_end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + if content and not content.endswith("\n"): + content += "\n" + new_content = (content + "\n" + section) if content else section + else: + new_content = section + + new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") + if ctx_path.casefold().endswith(".mdc"): + new_content = ensure_mdc_frontmatter(new_content) + with open(ctx_path, "wb") as fh: + fh.write(new_content.encode("utf-8")) + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + project_root = os.getcwd() + ext_config = ( + f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml" + ) + + if not os.path.isfile(ext_config): + _err(f"agent-context: {ext_config} not found; nothing to do.") + return 0 + + try: + import yaml + except ImportError: + _err( + "agent-context: PyYAML is required to parse extension config but is " + "not available in the current Python environment.\n" + " To resolve: pip install pyyaml (or install it into the environment " + "used by python3).\n" + " Context file will not be updated until PyYAML is importable." + ) + _err("agent-context: skipping update (see above for details).") + return 0 + + try: + with open(ext_config, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) + except Exception as exc: + _err( + f"agent-context: unable to parse {ext_config} ({exc}); " + "cannot update context." + ) + _err("agent-context: skipping update (see above for details).") + return 0 + if not isinstance(data, dict): + data = {} + + context_files = _collect_context_files(data, project_root) + if not context_files: + _err( + "agent-context: context_files/context_file not set in extension config; " + "nothing to do." + ) + return 0 + + for context_file in context_files: + error = _validate_context_file(project_root, context_file) + if error: + _err(error) + return 1 + + marker_start = _get_str(data, "context_markers", "start") or DEFAULT_START + marker_end = _get_str(data, "context_markers", "end") or DEFAULT_END + + plan_path = args[0] if args else "" + if not plan_path: + plan_path = _resolve_plan_path(project_root) + + section = _build_section(marker_start, marker_end, plan_path) + + for context_file in context_files: + ctx_path = os.path.join(project_root, context_file) + os.makedirs(os.path.dirname(ctx_path) or ".", exist_ok=True) + _upsert_section(ctx_path, marker_start, marker_end, section) + print(f"agent-context: updated {context_file}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/extensions/test_update_agent_context_python_parity.py b/tests/extensions/test_update_agent_context_python_parity.py new file mode 100644 index 000000000..92e8e20f8 --- /dev/null +++ b/tests/extensions/test_update_agent_context_python_parity.py @@ -0,0 +1,481 @@ +"""Parity tests: update_agent_context.py vs update-agent-context.sh/.ps1. + +Each test prepares two identical project trees, runs the bash script in one +and the Python port in the other, then compares exit codes, output (with +project roots normalized) and the resulting context-file bytes. PowerShell +tests compare the resulting file content only and are skipped when ``pwsh`` +is unavailable. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from tests.extensions.test_extension_agent_context import ( + BASH, + EXT_DIR, + POWERSHELL, + _bundled_script_env, +) + +PY_SCRIPT = EXT_DIR / "scripts" / "python" / "update_agent_context.py" +BASH_SCRIPT = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh" +PS_SCRIPT = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1" + +requires_posix_bash = pytest.mark.skipif( + not BASH or os.name == "nt", + reason="POSIX bash required for side-by-side parity runs", +) + + +def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [BASH, str(BASH_SCRIPT), *args], + cwd=project_root, + env=_bundled_script_env(project_root, for_bash=True), + capture_output=True, + text=True, + timeout=30, + ) + + +def run_python(project_root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(PY_SCRIPT), *args], + cwd=project_root, + env=_bundled_script_env(project_root), + capture_output=True, + text=True, + timeout=30, + ) + + +def run_powershell(project_root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(PS_SCRIPT), + *args, + ], + cwd=project_root, + env=_bundled_script_env(project_root), + capture_output=True, + text=True, + timeout=30, + ) + + +def normalize(text: str, project_root: Path) -> str: + return text.replace(str(project_root.resolve()), "__ROOT__").replace( + str(project_root), "__ROOT__" + ) + + +def write_config(project_root: Path, **overrides: object) -> None: + """Write the extension config as JSON (valid YAML, PS-parseable too).""" + cfg: dict = { + "context_file": overrides.get("context_file", ""), + "context_files": overrides.get("context_files", []), + "context_markers": overrides.get( + "context_markers", + {"start": "", "end": ""}, + ), + } + cfg_dir = project_root / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "agent-context-config.yml").write_text( + json.dumps(cfg), encoding="utf-8" + ) + + +def make_project(root: Path, **config: object) -> Path: + root.mkdir(parents=True, exist_ok=True) + write_config(root, **config) + return root + + +def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None: + plan = project_root / feature_dir / "plan.md" + plan.parent.mkdir(parents=True, exist_ok=True) + plan.write_text("# plan\n", encoding="utf-8") + (project_root / ".specify").mkdir(parents=True, exist_ok=True) + (project_root / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": feature_dir}), encoding="utf-8" + ) + + +def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]: + return ( + make_project(tmp_path / "proj-a", **config), + make_project(tmp_path / "proj-b", **config), + ) + + +def assert_parity( + bash: subprocess.CompletedProcess, + py: subprocess.CompletedProcess, + repo_a: Path, + repo_b: Path, +) -> None: + assert py.returncode == bash.returncode, py.stderr + bash.stderr + assert normalize(py.stdout, repo_b) == normalize(bash.stdout, repo_a) + assert normalize(py.stderr, repo_b) == normalize(bash.stderr, repo_a) + + +# ── Fresh file and upsert behavior ─────────────────────────────────────────── + + +@requires_posix_bash +def test_python_creates_fresh_context_file_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + add_plan(repo_a) + add_plan(repo_b) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content_a = (repo_a / "AGENTS.md").read_bytes() + content_b = (repo_b / "AGENTS.md").read_bytes() + assert content_a == content_b + assert b"at specs/001-demo/plan.md" in content_b + + +@requires_posix_bash +def test_python_replaces_existing_section_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + existing = ( + "# My project\n\n" + "\nstale section\n\n" + "\nTrailing prose stays.\n" + ) + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_text(encoding="utf-8") + assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8") + assert "stale section" not in content + assert content.startswith("# My project\n") + assert "Trailing prose stays." in content + + +@requires_posix_bash +@pytest.mark.parametrize( + "existing", + [ + "# Doc\n\ndangling start\n", + "dangling end\n\nrest\n", + "no markers at all", + ], + ids=["start-only", "end-only", "no-markers-no-newline"], +) +def test_python_handles_partial_markers_matching_bash( + tmp_path: Path, existing: str +) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes() + + +@requires_posix_bash +def test_python_custom_markers_matching_bash(tmp_path: Path) -> None: + markers = {"start": "", "end": ""} + repo_a, repo_b = twin_projects( + tmp_path, context_file="AGENTS.md", context_markers=markers + ) + existing = "intro\n\nold\n\noutro\n" + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_text(encoding="utf-8") + assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8") + assert "" in content + assert "old" not in content + + +@requires_posix_bash +def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None: + files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"] + repo_a, repo_b = twin_projects(tmp_path, context_files=files) + add_plan(repo_a) + add_plan(repo_b) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert bash.stdout.count("agent-context: updated") == 2 + for name in ("AGENTS.md", "docs/CONTEXT.md"): + assert (repo_a / name).read_bytes() == (repo_b / name).read_bytes() + + +@requires_posix_bash +def test_python_normalizes_crlf_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + existing = b"# Doc\r\n\r\n\r\nold\r\n\r\ntail\r\n" + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_bytes(existing) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"\r" not in content + + +@requires_posix_bash +def test_python_mdc_frontmatter_repair_matching_bash(tmp_path: Path) -> None: + mdc = ".cursor/rules/specify-rules.mdc" + cases = { + "missing": "# Rules\n", + "false-value": "---\ndescription: rules\nalwaysApply: false\n---\n\n# Rules\n", + "no-key": "---\ndescription: rules\n---\n\n# Rules\n", + } + for name, existing in cases.items(): + repo_a = make_project(tmp_path / f"a-{name}", context_file=mdc) + repo_b = make_project(tmp_path / f"b-{name}", context_file=mdc) + for repo in (repo_a, repo_b): + add_plan(repo) + target = repo / mdc + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / mdc).read_text(encoding="utf-8") + assert content == (repo_a / mdc).read_text(encoding="utf-8"), name + assert "alwaysApply: true" in content, name + + +# ── Plan-path resolution ───────────────────────────────────────────────────── + + +@requires_posix_bash +def test_python_explicit_plan_argument_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + + bash = run_bash(repo_a, "specs/009-explicit/plan.md") + py = run_python(repo_b, "specs/009-explicit/plan.md") + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"at specs/009-explicit/plan.md" in content + + +@requires_posix_bash +def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + now = time.time() + for repo in (repo_a, repo_b): + for feature, age in (("specs/000-old", 10), ("specs/001-new", 0)): + plan = repo / feature / "plan.md" + plan.parent.mkdir(parents=True, exist_ok=True) + plan.write_text("# plan\n", encoding="utf-8") + os.utime(plan, (now - age, now - age)) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"at specs/001-new/plan.md" in content + + +@requires_posix_bash +def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + now = time.time() + for repo in (repo_a, repo_b): + add_plan(repo, "specs/001-active") + stale = repo / "specs" / "000-stale" / "plan.md" + stale.parent.mkdir(parents=True, exist_ok=True) + stale.write_text("# plan\n", encoding="utf-8") + os.utime(repo / "specs" / "001-active" / "plan.md", (now - 10, now - 10)) + os.utime(stale, (now, now)) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"at specs/001-active/plan.md" in content + + +@requires_posix_bash +def test_python_no_plan_omits_at_line_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"\nat " not in content + + +# ── Config gates and path validation ───────────────────────────────────────── + + +@requires_posix_bash +def test_python_missing_config_matching_bash(tmp_path: Path) -> None: + repo_a = tmp_path / "proj-a" + repo_b = tmp_path / "proj-b" + repo_a.mkdir() + repo_b.mkdir() + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 0 + assert "not found; nothing to do." in py.stderr + + +@requires_posix_bash +def test_python_unparseable_config_matching_bash(tmp_path: Path) -> None: + repo_a = tmp_path / "proj-a" + repo_b = tmp_path / "proj-b" + for repo in (repo_a, repo_b): + cfg_dir = repo / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True) + (cfg_dir / "agent-context-config.yml").write_text( + "context_file: [unclosed\n", encoding="utf-8" + ) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 0 + assert "cannot update context." in py.stderr + assert "agent-context: skipping update (see above for details)." in py.stderr + + +@requires_posix_bash +def test_python_empty_config_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 0 + assert "context_files/context_file not set" in py.stderr + + +@requires_posix_bash +def test_python_self_seed_from_init_options_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path) + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / ".specify" / "init-options.json").write_text( + json.dumps({"integration": "claude"}), encoding="utf-8" + ) + shutil.copy( + EXT_DIR / "agent-context-defaults.json", + repo + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-defaults.json", + ) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert (repo_a / "CLAUDE.md").read_bytes() == (repo_b / "CLAUDE.md").read_bytes() + + +@requires_posix_bash +@pytest.mark.parametrize( + "bad_path", + ["/etc/AGENTS.md", "docs\\AGENTS.md", "../outside.md", "nested/../../escape.md"], + ids=["absolute", "backslash", "dotdot", "nested-dotdot"], +) +def test_python_rejects_escaping_paths_matching_bash( + tmp_path: Path, bad_path: str +) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file=bad_path) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 1 + assert not (repo_b / "AGENTS.md").exists() + + +# ── PowerShell parity (content only) ───────────────────────────────────────── + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None: + repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md") + repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md") + add_plan(repo_a) + add_plan(repo_b) + + ps = run_powershell(repo_a) + py = run_python(repo_b) + + assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr + assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes() + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_python_upsert_matches_powershell(tmp_path: Path) -> None: + repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md") + repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md") + existing = ( + "# My project\n\n" + "\nstale\n\n" + "\ntail\n" + ) + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + ps = run_powershell(repo_a) + py = run_python(repo_b) + + assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr + assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()