mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* fix: derive plan path from feature.json in update-agent-context When `plan_path` is omitted, prefer `.specify/feature.json` (written by /speckit-specify) over the mtime heuristic. The old approach picked the most recently modified `specs/*/plan.md`, which could inject an unrelated plan into CLAUDE.md if another spec's plan was touched after the active feature directory was created but before its own plan.md existed. Bash: handle both relative and absolute feature_directory values, normalizing absolute paths back to project-relative for the context file. Fall back to mtime only when feature.json is absent or the derived plan.md does not yet exist. PowerShell: same logic, PS 5.1-compatible (nested Join-Path, IsPathRooted guard to avoid Unix Join-Path mis-joining absolute ChildPaths, manual prefix-strip instead of GetRelativePath). Fixes #3067 * fix: address Copilot review feedback on update-agent-context - bash: add explicit encoding="utf-8" to feature.json open() call - powershell: replace GetRelativePath (.NET 5+ only) with manual prefix-strip in mtime fallback for PS 5.1 compatibility - tests: add coverage for absolute feature_directory values (under and outside PROJECT_ROOT) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test: replace time.sleep with os.utime and strengthen PS normalization assertion * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: normalize trailing slash and guard non-string feature_directory in PS script * Fix: use .resolve().as_posix(). Valid. The PS tests run on Windows where str(tmp_path) uses backslashes, but the PS script normalizes output to forward slashes. Assertions like assert str(tmp_path) not in ctx become false negatives on Windows CI. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: use context manager for feature.json open() in bash heredoc * test: add PS coverage for absolute feature_directory outside project root * fix: guard null feature_directory, re-check empty after trailing-slash strip, fix blank line * test: add stale plan to absolute-path tests so feature.json preference is actually exercised * test: convert absolute paths to MSYS2 style for Git-for-Windows bash compatibility * fix: revert PS test to native path, fix bash outside-root assertion for Git bash * fix: use _to_bash_path in not-in assertion for Git bash Windows compat * fix: add ConvertFrom-Json fallback in PS script, write test config as JSON * fix: use OS-appropriate StringComparison in PS prefix-strip (matches common.ps1) * fix: emit project-relative POSIX path from mtime fallback; use upstream test helpers * fix: write config as JSON directly, drop _install_agent_context_config * fix: normalize backslashes to forward slashes in feature_directory before path ops * fix: treat drive-qualified paths (C:/...) as absolute after backslash normalization * fix: resolve symlinks when computing relative plan path; use UTF8 encoding in PS ConvertFrom-Yaml path * fix: use bash-side path for outside-root case to avoid WindowsPath backslashes * fix: use .as_posix() instead of PurePosixPath() to avoid backslashes on native Windows Python * fix: resolve ./.. segments in PS feature_directory via GetFullPath before relativizing * fix: replace $IsWindows guard with OSVersion.Platform check for PS 5.1 StrictMode compat * fix: guard empty relDir to avoid leading slash in PlanPath when feature_directory is project root * fix: remove unused PurePosixPath import; fix stale PS comment after ConvertFrom-Json fallback was added * fix: use cand.as_posix() for outside-root path instead of raw bash-side argv --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
212 lines
8.3 KiB
Python
212 lines
8.3 KiB
Python
"""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": "<!-- SPECKIT START -->",
|
|
"end": "<!-- SPECKIT 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
|