mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 23:59:55 +08:00
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>
This commit is contained in:
352
extensions/agent-context/scripts/python/update_agent_context.py
Normal file
352
extensions/agent-context/scripts/python/update_agent_context.py
Normal file
@@ -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 = "<!-- SPECKIT START -->"
|
||||
DEFAULT_END = "<!-- SPECKIT 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())
|
||||
481
tests/extensions/test_update_agent_context_python_parity.py
Normal file
481
tests/extensions/test_update_agent_context_python_parity.py
Normal file
@@ -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": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT 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"
|
||||
"<!-- SPECKIT START -->\nstale section\n<!-- SPECKIT END -->\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<!-- SPECKIT START -->\ndangling start\n",
|
||||
"dangling end\n<!-- SPECKIT END -->\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": "<!-- CTX BEGIN -->", "end": "<!-- CTX FINISH -->"}
|
||||
repo_a, repo_b = twin_projects(
|
||||
tmp_path, context_file="AGENTS.md", context_markers=markers
|
||||
)
|
||||
existing = "intro\n<!-- CTX BEGIN -->\nold\n<!-- CTX FINISH -->\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 "<!-- CTX BEGIN -->" 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<!-- SPECKIT START -->\r\nold\r\n<!-- SPECKIT END -->\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"
|
||||
"<!-- SPECKIT START -->\nstale\n<!-- SPECKIT END -->\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()
|
||||
Reference in New Issue
Block a user