mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
fix(agent-context): support multiple context files safely (#2969)
* fix(agent-context): support multiple context files safely * fix(agent-context): harden context file validation * fix(agent-context): preserve disabled context target * fix(agent-context): address review follow-ups * fix(agent-context): dedupe PowerShell context files * fix(agent-context): align context file dedupe * fix(agent-context): align bash context file dedupe * fix(agent-context): preserve disabled display target * fix(agent-context): require yaml-capable updater python * fix(agent-context): preserve context files config * fix(agent-context): align context file fallbacks * fix(agent-context): share context file resolution --------- Co-authored-by: AustinZ21 <AustinZ21@users.noreply.github.com>
This commit is contained in:
@@ -3,8 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli import (
|
||||
@@ -13,18 +18,25 @@ from specify_cli import (
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
|
||||
BASH = shutil.which("bash")
|
||||
POWERSHELL = (
|
||||
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
)
|
||||
|
||||
|
||||
def _write_ext_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write a minimal agent-context extension config."""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_files": overrides.get("context_files", []),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{
|
||||
@@ -72,6 +84,14 @@ class TestExtensionLayout:
|
||||
assert cmd.is_file()
|
||||
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
|
||||
|
||||
def test_command_file_documents_context_file_constraints(self):
|
||||
text = (
|
||||
EXT_DIR / "commands" / "speckit.agent-context.update.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "context file(s)" in text
|
||||
assert "Windows drive paths" in text
|
||||
assert "backslash separators" in text
|
||||
|
||||
def test_bundled_scripts_exist(self):
|
||||
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
|
||||
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
|
||||
@@ -107,6 +127,184 @@ class _CtxIntegration(ClaudeIntegration):
|
||||
"""Use Claude as a concrete integration with a context_file."""
|
||||
|
||||
|
||||
class _NoContextIntegration(IntegrationBase):
|
||||
"""Minimal integration with no context_file for base-class fallback tests."""
|
||||
|
||||
|
||||
def _install_agent_context_config(project_root: Path, **overrides: object) -> None:
|
||||
_write_ext_config(project_root, **overrides)
|
||||
|
||||
|
||||
def _bash_posix_path(path: Path) -> str:
|
||||
"""Convert a Windows path to the POSIX form used by the available bash."""
|
||||
resolved = str(path.resolve())
|
||||
if os.name != "nt":
|
||||
return resolved
|
||||
|
||||
if BASH:
|
||||
converted = subprocess.run(
|
||||
[
|
||||
BASH,
|
||||
"-lc",
|
||||
"command -v cygpath >/dev/null 2>&1 && cygpath -u \"$1\"",
|
||||
"bash",
|
||||
resolved,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if converted.returncode == 0 and converted.stdout.strip():
|
||||
return converted.stdout.strip()
|
||||
|
||||
drive = path.drive.rstrip(":").lower()
|
||||
posix = path.as_posix()
|
||||
return f"/mnt/{drive}{posix[2:]}" if drive else posix
|
||||
|
||||
|
||||
def _ensure_test_python_on_path(project_root: Path) -> Path:
|
||||
"""Create python/python3 shims that run the current pytest interpreter."""
|
||||
shim_dir = project_root / ".test-python-bin"
|
||||
shim_dir.mkdir(exist_ok=True)
|
||||
python_exe = Path(sys.executable).resolve()
|
||||
shell_python = _bash_posix_path(python_exe)
|
||||
|
||||
for name in ("python", "python3"):
|
||||
shell_shim = shim_dir / name
|
||||
shell_shim.write_text(
|
||||
f"#!/usr/bin/env sh\nexec {shlex_quote(shell_python)} \"$@\"\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
shell_shim.chmod(0o755)
|
||||
|
||||
if os.name == "nt":
|
||||
cmd_shim = shim_dir / f"{name}.cmd"
|
||||
cmd_shim.write_text(
|
||||
f'@echo off\r\n"{python_exe}" %*\r\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return shim_dir
|
||||
|
||||
|
||||
def _current_pythonpath() -> str:
|
||||
"""Return sys.path entries needed by child script interpreters."""
|
||||
entries = [
|
||||
entry
|
||||
for entry in sys.path
|
||||
if isinstance(entry, str) and entry
|
||||
]
|
||||
existing = os.environ.get("PYTHONPATH")
|
||||
if existing:
|
||||
entries.extend(entry for entry in existing.split(os.pathsep) if entry)
|
||||
return os.pathsep.join(dict.fromkeys(entries))
|
||||
|
||||
|
||||
def _bundled_script_env(
|
||||
project_root: Path,
|
||||
*,
|
||||
for_bash: bool = False,
|
||||
speckit_python: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
shim_dir = _ensure_test_python_on_path(project_root)
|
||||
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
|
||||
env["SPECKIT_PYTHON"] = (
|
||||
speckit_python
|
||||
if speckit_python is not None
|
||||
else (_bash_posix_path(Path(sys.executable)) if for_bash else sys.executable)
|
||||
)
|
||||
pythonpath = _current_pythonpath()
|
||||
if pythonpath:
|
||||
env["PYTHONPATH"] = pythonpath
|
||||
return env
|
||||
|
||||
|
||||
def _run_bash_agent_context_script(
|
||||
project_root: Path,
|
||||
*,
|
||||
speckit_python: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
|
||||
env = _bundled_script_env(
|
||||
project_root,
|
||||
for_bash=True,
|
||||
speckit_python=speckit_python,
|
||||
)
|
||||
if os.name == "nt":
|
||||
root = _bash_posix_path(project_root)
|
||||
script_path = _bash_posix_path(script)
|
||||
shim_dir = _bash_posix_path(_ensure_test_python_on_path(project_root))
|
||||
command = (
|
||||
f"export PATH={shlex_quote(shim_dir)}:\"$PATH\"; "
|
||||
f"cd {shlex_quote(root)} && {shlex_quote(script_path)}"
|
||||
)
|
||||
return subprocess.run(
|
||||
[BASH, "-lc", command],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return subprocess.run(
|
||||
[BASH, str(script)],
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def shlex_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
env = _bundled_script_env(project_root)
|
||||
return subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script),
|
||||
],
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _run_powershell_agent_context_script_with_env(
|
||||
project_root: Path,
|
||||
*,
|
||||
speckit_python: str,
|
||||
) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
env = _bundled_script_env(project_root, speckit_python=speckit_python)
|
||||
return subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script),
|
||||
],
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
class TestContextMarkerResolution:
|
||||
def test_defaults_when_ext_config_missing(self, tmp_path):
|
||||
i = _CtxIntegration()
|
||||
@@ -200,6 +398,142 @@ class TestUpsertWithCustomMarkers:
|
||||
assert text.startswith("# header\n")
|
||||
assert "footer" in text
|
||||
|
||||
def test_upsert_uses_configured_context_files(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md"],
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(
|
||||
tmp_path, plan_path="specs/001-foo/plan.md"
|
||||
)
|
||||
assert result == tmp_path / "AGENTS.md"
|
||||
for name in ("AGENTS.md", "CLAUDE.md"):
|
||||
text = (tmp_path / name).read_text(encoding="utf-8")
|
||||
assert IntegrationBase.CONTEXT_MARKER_START in text
|
||||
assert "specs/001-foo/plan.md" in text
|
||||
|
||||
def test_context_files_deduplicate_with_platform_semantics(self, tmp_path):
|
||||
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
|
||||
)
|
||||
|
||||
files = _CtxIntegration()._resolve_context_files(tmp_path)
|
||||
|
||||
assert files == ["AGENTS.md", "CLAUDE.md"]
|
||||
|
||||
def test_empty_context_files_falls_back_to_config_context_file(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=[],
|
||||
)
|
||||
|
||||
files = _CtxIntegration()._resolve_context_files(tmp_path)
|
||||
|
||||
assert files == ["AGENTS.md"]
|
||||
|
||||
def test_config_context_file_takes_precedence_over_class_default(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
)
|
||||
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(
|
||||
tmp_path, plan_path="specs/001-foo/plan.md"
|
||||
)
|
||||
|
||||
assert result == tmp_path / "AGENTS.md"
|
||||
assert (tmp_path / "AGENTS.md").exists()
|
||||
assert not (tmp_path / "CLAUDE.md").exists()
|
||||
|
||||
def test_config_context_file_fallback_rejects_invalid_path(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="../outside.md",
|
||||
context_files=[],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="project-relative|must not contain"):
|
||||
_CtxIntegration()._resolve_context_files(tmp_path)
|
||||
|
||||
def test_remove_uses_configured_context_files(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md"],
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
for name in ("AGENTS.md", "CLAUDE.md"):
|
||||
(tmp_path / name).write_text(
|
||||
f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
|
||||
f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert i.remove_context_section(tmp_path) is True
|
||||
for name in ("AGENTS.md", "CLAUDE.md"):
|
||||
text = (tmp_path / name).read_text(encoding="utf-8")
|
||||
assert "body" not in text
|
||||
assert "head" in text
|
||||
assert "tail" in text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[
|
||||
"../outside.md",
|
||||
"nested/../../outside.md",
|
||||
"nested\\outside.md",
|
||||
str(Path("/tmp/outside.md")),
|
||||
"C:/tmp/outside.md",
|
||||
"C:tmp/outside.md",
|
||||
],
|
||||
)
|
||||
def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["AGENTS.md", bad_path],
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
with pytest.raises(ValueError, match="project-relative|must not contain"):
|
||||
i.upsert_context_section(tmp_path)
|
||||
|
||||
assert not (tmp_path / "AGENTS.md").exists()
|
||||
assert not (tmp_path.parent / "outside.md").exists()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[
|
||||
"../outside.md",
|
||||
"nested\\outside.md",
|
||||
str(Path("/tmp/outside.md")),
|
||||
"C:/tmp/outside.md",
|
||||
"C:tmp/outside.md",
|
||||
],
|
||||
)
|
||||
def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["AGENTS.md", bad_path],
|
||||
)
|
||||
outside = tmp_path.parent / "outside.md"
|
||||
outside.write_text(
|
||||
f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
|
||||
f"{IntegrationBase.CONTEXT_MARKER_END}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
with pytest.raises(ValueError, match="project-relative|must not contain"):
|
||||
i.remove_context_section(tmp_path)
|
||||
|
||||
assert "body" in outside.read_text(encoding="utf-8")
|
||||
|
||||
def test_remove_uses_custom_markers(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
@@ -270,6 +604,17 @@ class TestExtensionEnabledGate:
|
||||
assert result is None
|
||||
assert not (tmp_path / "CLAUDE.md").exists()
|
||||
|
||||
def test_upsert_disabled_ignores_bad_context_files_config(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["../disabled-upsert-outside.md"],
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
assert i.upsert_context_section(tmp_path) is None
|
||||
assert not (tmp_path.parent / "disabled-upsert-outside.md").exists()
|
||||
|
||||
def test_remove_skipped_when_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
@@ -283,6 +628,382 @@ class TestExtensionEnabledGate:
|
||||
# File must be unchanged when extension is disabled
|
||||
assert ctx.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["../disabled-remove-outside.md"],
|
||||
)
|
||||
outside = tmp_path.parent / "disabled-remove-outside.md"
|
||||
outside.write_text(
|
||||
f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
|
||||
f"{IntegrationBase.CONTEXT_MARKER_END}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
assert i.remove_context_section(tmp_path) is False
|
||||
assert "body" in outside.read_text(encoding="utf-8")
|
||||
|
||||
def test_context_file_display_disabled_uses_config_context_file(
|
||||
self, tmp_path
|
||||
):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["../outside.md"],
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
assert i._context_file_display(tmp_path) == "AGENTS.md"
|
||||
|
||||
def test_context_file_display_disabled_without_context_file_returns_string(
|
||||
self, tmp_path
|
||||
):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _NoContextIntegration()
|
||||
assert i._context_file_display(tmp_path) == ""
|
||||
|
||||
|
||||
class TestSkillPlaceholderContextValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[
|
||||
"../outside.md",
|
||||
"nested/../../outside.md",
|
||||
"nested\\outside.md",
|
||||
str(Path("/tmp/outside.md")),
|
||||
"C:/tmp/outside.md",
|
||||
"C:tmp/outside.md",
|
||||
],
|
||||
)
|
||||
def test_context_files_reject_invalid_config_paths(self, tmp_path, bad_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md", bad_path],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="project-relative|must not contain"):
|
||||
CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[
|
||||
"../outside.md",
|
||||
"C:tmp/outside.md",
|
||||
],
|
||||
)
|
||||
def test_context_file_rejects_invalid_config_path(self, tmp_path, bad_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file=bad_path,
|
||||
context_files=[],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="project-relative|must not contain"):
|
||||
CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
def test_enabled_extension_rejects_invalid_legacy_init_options_path(
|
||||
self, tmp_path
|
||||
):
|
||||
save_init_options(tmp_path, {"context_file": "../outside.md"})
|
||||
|
||||
with pytest.raises(ValueError, match="must not contain"):
|
||||
CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
def test_disabled_extension_ignores_invalid_context_files(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["../outside.md"],
|
||||
)
|
||||
save_init_options(tmp_path, {"context_file": "AGENTS.md"})
|
||||
|
||||
content = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert content == "Read AGENTS.md"
|
||||
|
||||
def test_disabled_extension_uses_extension_context_file_before_init_options(
|
||||
self, tmp_path
|
||||
):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["CLAUDE.md"],
|
||||
)
|
||||
save_init_options(tmp_path, {"context_file": "LEGACY.md"})
|
||||
|
||||
content = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert content == "Read AGENTS.md"
|
||||
|
||||
def test_context_files_deduplicate_with_platform_semantics(self, tmp_path):
|
||||
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
|
||||
)
|
||||
|
||||
content = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert content == "Read AGENTS.md, CLAUDE.md"
|
||||
|
||||
|
||||
class TestBundledUpdaterPathValidation:
|
||||
def test_bundled_script_env_makes_yaml_importable(self, tmp_path):
|
||||
env = _bundled_script_env(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[env["SPECKIT_PYTHON"], "-c", "import yaml"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_trims_context_file_fallback(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file=" AGENTS.md ",
|
||||
context_files=[],
|
||||
)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
|
||||
assert (project / "AGENTS.md").exists()
|
||||
assert not (project / " AGENTS.md ").exists()
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_rejects_symlink_escape(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["link/out.md"],
|
||||
)
|
||||
|
||||
if os.name == "nt":
|
||||
root = _bash_posix_path(tmp_path)
|
||||
create_link = subprocess.run(
|
||||
[
|
||||
BASH,
|
||||
"-lc",
|
||||
f"ln -s {shlex_quote(root + '/outside')} "
|
||||
f"{shlex_quote(root + '/project/link')}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if create_link.returncode != 0:
|
||||
pytest.skip(f"symlink unavailable: {create_link.stderr}")
|
||||
else:
|
||||
try:
|
||||
(project / "link").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink unavailable: {exc}")
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "resolves outside the project root" in result.stderr
|
||||
assert not (outside / "out.md").exists()
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_deduplicates_context_files_in_order(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
|
||||
)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
output = result.stderr + result.stdout
|
||||
assert output.count("agent-context: updated AGENTS.md") == 1
|
||||
assert output.count("agent-context: updated CLAUDE.md") == 1
|
||||
assert "agent-context: updated agents.md" not in output
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
|
||||
result = _run_bash_agent_context_script(
|
||||
project,
|
||||
speckit_python="/definitely/missing/python",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
|
||||
assert (project / "AGENTS.md").exists()
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_rejects_backslash_context_files(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["nested\\AGENTS.md"],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "must not contain backslash separators" in (
|
||||
result.stderr + result.stdout
|
||||
)
|
||||
assert not (project / "nested" / "AGENTS.md").exists()
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_rejects_drive_qualified_context_files(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["C:tmp/outside.md"],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "must be project-relative paths" in (result.stderr + result.stdout)
|
||||
assert not (project / "tmp" / "outside.md").exists()
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
output = result.stderr + result.stdout
|
||||
assert output.count("agent-context: updated AGENTS.md") == 1
|
||||
assert output.count("agent-context: updated CLAUDE.md") == 1
|
||||
assert "agent-context: updated agents.md" not in output
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script_with_env(
|
||||
project,
|
||||
speckit_python=str(project / "missing-python"),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
|
||||
assert (project / "AGENTS.md").exists()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
POWERSHELL is None or os.name != "nt",
|
||||
reason="Windows PowerShell junction test requires Windows",
|
||||
)
|
||||
def test_powershell_script_rejects_junction_escape(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["link/out.md"],
|
||||
)
|
||||
|
||||
create_link = subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
(
|
||||
"New-Item -ItemType Junction "
|
||||
f"-Path {str(project / 'link')!r} "
|
||||
f"-Target {str(outside)!r} | Out-Null"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if create_link.returncode != 0:
|
||||
pytest.skip(f"junction unavailable: {create_link.stderr}")
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "resolves outside the project root" in (result.stderr + result.stdout)
|
||||
assert not (outside / "out.md").exists()
|
||||
|
||||
|
||||
# ── Extension config writers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -349,6 +1070,65 @@ class TestExtensionConfigWriters:
|
||||
assert cfg["context_file"] == i.context_file
|
||||
assert "context_markers" in cfg
|
||||
|
||||
def test_update_init_options_preserves_context_files(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md"],
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i, script_type="sh")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_file"] == i.context_file
|
||||
assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"]
|
||||
|
||||
def test_update_init_options_preserves_empty_context_files(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="AGENTS.md",
|
||||
context_files=[],
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i, script_type="sh")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_file"] == i.context_file
|
||||
assert cfg["context_files"] == []
|
||||
|
||||
def test_update_init_options_normalizes_invalid_context_files(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
_write_ext_config(tmp_path, context_file="AGENTS.md")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
cfg["context_files"] = "AGENTS.md"
|
||||
_save_agent_context_config(tmp_path, cfg)
|
||||
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i, script_type="sh")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_file"] == i.context_file
|
||||
assert cfg["context_files"] == []
|
||||
|
||||
def test_clear_init_options_clears_context_files(self, tmp_path):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{"integration": "claude", "ai": "claude"},
|
||||
)
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md"],
|
||||
)
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg.get("context_file") == ""
|
||||
assert "context_files" not in cfg
|
||||
|
||||
def test_update_init_options_preserves_custom_markers(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
|
||||
Reference in New Issue
Block a user