Skip to content

Commit 70a71b4

Browse files
authored
fix(agent-context): skip non-file plan candidates (#5222)
Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous)
1 parent 38c40ae commit 70a71b4

5 files changed

Lines changed: 301 additions & 5 deletions

File tree

‎extensions/agent-context/extension.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ schema_version: "1.0"
33
extension:
44
id: agent-context
55
name: "Coding Agent Context"
6-
version: "1.0.2"
6+
version: "1.0.3"
77
description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers"
88
author: spec-kit-core
99
repository: https://github.com/github/spec-kit

‎extensions/agent-context/scripts/bash/update-agent-context.sh‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ PY
310310
_plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY'
311311
import sys
312312
from pathlib import Path
313+
from stat import S_ISREG
313314
root = Path(sys.argv[1]).resolve()
314315
specs = root / "specs"
315316
@@ -329,7 +330,14 @@ def _resolved_rel(p):
329330
candidates = []
330331
for p in specs.rglob("plan.md"):
331332
rel = _resolved_rel(p)
332-
if rel:
333+
if rel is None:
334+
continue
335+
# Follow file links without suppressing errors for contained candidates.
336+
try:
337+
mode = p.stat().st_mode
338+
except (FileNotFoundError, NotADirectoryError):
339+
continue
340+
if S_ISREG(mode):
333341
candidates.append((p, rel))
334342
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
335343
if candidates:

‎extensions/agent-context/scripts/python/update_agent_context.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import re
2424
import sys
2525
from pathlib import Path
26+
from stat import S_ISREG
2627

2728
DEFAULT_START = "<!-- SPECKIT START -->"
2829
DEFAULT_END = "<!-- SPECKIT END -->"
@@ -193,7 +194,14 @@ def _resolved_rel(p: Path) -> Path | None:
193194
candidates = []
194195
for p in specs.rglob("plan.md"):
195196
rel = _resolved_rel(p)
196-
if rel is not None:
197+
if rel is None:
198+
continue
199+
# Follow file links without suppressing errors for contained candidates.
200+
try:
201+
mode = p.stat().st_mode
202+
except (FileNotFoundError, NotADirectoryError):
203+
continue
204+
if S_ISREG(mode):
197205
candidates.append((p, rel))
198206
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
199207
if candidates:

‎extensions/catalog.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"agent-context": {
77
"name": "Coding Agent Context",
88
"id": "agent-context",
9-
"version": "1.0.2",
9+
"version": "1.0.3",
1010
"description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers",
1111
"author": "spec-kit-core",
1212
"repository": "https://github.com/github/spec-kit",

‎tests/extensions/test_update_agent_context_python_parity.py‎

Lines changed: 281 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,27 @@
99

1010
from __future__ import annotations
1111

12+
import errno
13+
import io
1214
import json
1315
import os
16+
import re
17+
import runpy
1418
import shutil
1519
import subprocess
1620
import sys
1721
import time
22+
from contextlib import redirect_stdout
1823
from pathlib import Path
1924

2025
import pytest
2126

27+
from tests.conftest import requires_bash
2228
from tests.extensions.test_extension_agent_context import (
2329
BASH,
2430
EXT_DIR,
2531
POWERSHELL,
32+
_bash_posix_path,
2633
_bundled_script_env,
2734
)
2835

@@ -38,7 +45,7 @@
3845

3946
def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess:
4047
return subprocess.run(
41-
[BASH, str(BASH_SCRIPT), *args],
48+
[BASH, _bash_posix_path(BASH_SCRIPT), *args],
4249
cwd=project_root,
4350
env=_bundled_script_env(project_root, for_bash=True),
4451
capture_output=True,
@@ -343,6 +350,279 @@ def test_python_mtime_fallback_finds_nested_plan_matching_bash(
343350
assert b"at specs/backend/001-nested/plan.md" in content
344351

345352

353+
@pytest.mark.parametrize(
354+
"run_script",
355+
[
356+
pytest.param(run_python, id="python"),
357+
pytest.param(run_bash, id="bash", marks=requires_bash),
358+
],
359+
)
360+
@pytest.mark.parametrize("has_regular_plan", [False, True], ids=["alone", "with-plan"])
361+
@pytest.mark.parametrize(
362+
"candidate_kind",
363+
[
364+
"regular-file",
365+
"directory",
366+
"dangling-link",
367+
"non-directory-link",
368+
"directory-link",
369+
"file-link",
370+
"escaping-link",
371+
],
372+
)
373+
def test_mtime_fallback_selects_only_in_project_files(
374+
tmp_path: Path, run_script, has_regular_plan: bool, candidate_kind: str
375+
) -> None:
376+
repo = make_project(tmp_path / "project", context_file="AGENTS.md")
377+
expected_plan = ""
378+
if has_regular_plan:
379+
expected_plan = "specs/backend/001-valid/plan.md"
380+
plan = repo / expected_plan
381+
plan.parent.mkdir(parents=True)
382+
plan.write_text("# older plan\n", encoding="utf-8")
383+
os.utime(plan, (1_600_000_000, 1_600_000_000))
384+
385+
candidate = repo / "specs/999-candidate/plan.md"
386+
candidate.parent.mkdir(parents=True)
387+
if candidate_kind == "regular-file":
388+
candidate.write_text("# newer plan\n", encoding="utf-8")
389+
os.utime(candidate, (1_700_000_000, 1_700_000_000))
390+
expected_plan = "specs/999-candidate/plan.md"
391+
elif candidate_kind == "directory":
392+
candidate.mkdir()
393+
os.utime(candidate, (1_700_000_000, 1_700_000_000))
394+
else:
395+
target = repo / "plans/current.md"
396+
if candidate_kind == "escaping-link":
397+
target = tmp_path / "outside.md"
398+
if candidate_kind == "non-directory-link":
399+
target.parent.parent.mkdir(parents=True, exist_ok=True)
400+
target.parent.write_text("not a directory\n", encoding="utf-8")
401+
elif candidate_kind == "directory-link":
402+
target.mkdir(parents=True)
403+
elif candidate_kind != "dangling-link":
404+
target.parent.mkdir(parents=True, exist_ok=True)
405+
target.write_text("# linked plan\n", encoding="utf-8")
406+
try:
407+
candidate.symlink_to(
408+
target, target_is_directory=candidate_kind == "directory-link"
409+
)
410+
except (OSError, NotImplementedError) as exc:
411+
pytest.skip(f"symlinks unavailable: {exc}")
412+
if candidate_kind not in ("dangling-link", "non-directory-link"):
413+
os.utime(target, (1_700_000_000, 1_700_000_000))
414+
if candidate_kind == "file-link":
415+
# Valid file links retain target mtime ordering and resolved paths,
416+
# including targets outside specs/ that are still in the project.
417+
expected_plan = "plans/current.md"
418+
419+
result = run_script(repo)
420+
421+
assert result.returncode == 0, result.stderr
422+
assert result.stderr == ""
423+
assert result.stdout == "agent-context: updated AGENTS.md\n"
424+
expected = (
425+
"<!-- SPECKIT START -->\n"
426+
"For additional context about technologies to be used, project structure,\n"
427+
"shell commands, and other important information, read the current plan\n"
428+
)
429+
if expected_plan:
430+
expected += f"at {expected_plan}\n"
431+
expected += "<!-- SPECKIT END -->\n"
432+
assert (repo / "AGENTS.md").read_bytes() == expected.encode("utf-8")
433+
434+
435+
@pytest.fixture(params=["python", "bash-python"])
436+
def mtime_resolver(request, monkeypatch):
437+
"""Run actual resolver code; the Bash heredoc needs no shell for IO injection."""
438+
if request.param == "python":
439+
return runpy.run_path(str(PY_SCRIPT))["_resolve_plan_path"]
440+
441+
matches = re.findall(
442+
r"_plan_rel=\"\$\(\"\$_python\" - \"\$PROJECT_ROOT\" <<'PY'\n(.*?)\nPY\n\)\"",
443+
BASH_SCRIPT.read_text(encoding="utf-8"),
444+
re.DOTALL,
445+
)
446+
assert len(matches) == 1, "expected exactly one mtime fallback heredoc"
447+
code = compile(matches[0], str(BASH_SCRIPT), "exec")
448+
449+
def resolve(project_root):
450+
output = io.StringIO()
451+
with monkeypatch.context() as patch, redirect_stdout(output):
452+
patch.setattr(sys, "argv", ["-", str(project_root)])
453+
exec(code, {})
454+
return output.getvalue().strip()
455+
456+
return resolve
457+
458+
459+
@pytest.fixture(params=[False, True], ids=["alone", "with-plan"])
460+
def mtime_candidate(tmp_path, request):
461+
repo = make_project(tmp_path.resolve() / "project", context_file="AGENTS.md")
462+
expected_plan = ""
463+
if request.param:
464+
expected_plan = "specs/backend/001-valid/plan.md"
465+
plan = repo / expected_plan
466+
plan.parent.mkdir(parents=True)
467+
plan.write_text("# older plan\n", encoding="utf-8")
468+
os.utime(plan, (1_600_000_000, 1_600_000_000))
469+
candidate = repo / "specs/999-candidate/plan.md"
470+
candidate.parent.mkdir(parents=True)
471+
candidate.write_text("# candidate\n", encoding="utf-8")
472+
return repo, candidate, expected_plan
473+
474+
475+
def _patch_candidate_io(
476+
monkeypatch, specs_root, candidate, stat_errors, *, resolved=None, resolve_error=None
477+
):
478+
"""Inject at os.stat so Python 3.14's os.path.isfile path is covered too.
479+
480+
Resolve is controlled separately: these tests distinguish excluded paths
481+
from stat failures after successful in-project containment, on every OS.
482+
"""
483+
original_rglob = Path.rglob
484+
original_stat = os.stat
485+
original_resolve = Path.resolve
486+
# Literal-name globbing can stat candidates before yielding them. Capture
487+
# real enumeration first so injected errors reach only resolver operations.
488+
candidates = tuple(original_rglob(specs_root, "plan.md"))
489+
assert candidate in candidates, "expected the candidate in real specs enumeration"
490+
calls = []
491+
492+
def rglob(path, pattern, *args, **kwargs):
493+
if path == specs_root and pattern == "plan.md":
494+
return iter(candidates)
495+
return original_rglob(path, pattern, *args, **kwargs)
496+
497+
def stat(path, *args, **kwargs):
498+
if isinstance(path, (str, os.PathLike)) and Path(path) == candidate:
499+
calls.append(path)
500+
error = stat_errors.get(len(calls))
501+
if error is not None:
502+
raise OSError(error, "injected candidate stat error", str(path))
503+
return original_stat(path, *args, **kwargs)
504+
505+
def resolve(path, *args, **kwargs):
506+
if path == candidate:
507+
if resolve_error is not None:
508+
raise OSError(resolve_error, "injected resolve error", str(path))
509+
return candidate if resolved is None else resolved
510+
return original_resolve(path, *args, **kwargs)
511+
512+
monkeypatch.setattr(Path, "rglob", rglob)
513+
monkeypatch.setattr(os, "stat", stat)
514+
monkeypatch.setattr(Path, "resolve", resolve)
515+
return calls
516+
517+
518+
@pytest.mark.parametrize("resolve_fails", [False, True], ids=["external", "resolve-error"])
519+
def test_mtime_fallback_ignores_excluded_candidate_errors(
520+
monkeypatch, mtime_resolver, mtime_candidate, resolve_fails
521+
):
522+
repo, candidate, expected_plan = mtime_candidate
523+
calls = _patch_candidate_io(
524+
monkeypatch,
525+
repo / "specs",
526+
candidate,
527+
{1: errno.EACCES},
528+
resolved=repo.parent / "private/plan.md",
529+
resolve_error=errno.EACCES if resolve_fails else None,
530+
)
531+
assert mtime_resolver(str(repo)) == expected_plan
532+
assert calls == [], "excluded candidates must not reach the stat precheck"
533+
534+
535+
@pytest.mark.parametrize("error", [errno.EACCES, errno.EPERM, errno.EIO])
536+
def test_mtime_fallback_preserves_contained_stat_errors(
537+
monkeypatch, mtime_resolver, mtime_candidate, error
538+
):
539+
repo, candidate, _ = mtime_candidate
540+
calls = _patch_candidate_io(monkeypatch, repo / "specs", candidate, {1: error})
541+
with pytest.raises(OSError) as exc:
542+
mtime_resolver(str(repo))
543+
assert type(exc.value) is type(OSError(error, ""))
544+
assert exc.value.errno == error
545+
assert len(calls) == 1
546+
547+
548+
@pytest.mark.parametrize("error", [errno.ENOENT, errno.ENOTDIR])
549+
def test_mtime_fallback_skips_missing_candidate_at_precheck(
550+
monkeypatch, mtime_resolver, mtime_candidate, error
551+
):
552+
repo, candidate, expected_plan = mtime_candidate
553+
calls = _patch_candidate_io(monkeypatch, repo / "specs", candidate, {1: error})
554+
assert mtime_resolver(str(repo)) == expected_plan
555+
assert len(calls) == 1
556+
557+
558+
@pytest.mark.parametrize("error", [errno.EACCES, errno.ENOENT, errno.ENOTDIR])
559+
def test_mtime_fallback_propagates_sort_stat_errors(
560+
monkeypatch, mtime_resolver, mtime_candidate, error
561+
):
562+
repo, candidate, _ = mtime_candidate
563+
calls = _patch_candidate_io(monkeypatch, repo / "specs", candidate, {2: error})
564+
with pytest.raises(OSError) as exc:
565+
mtime_resolver(str(repo))
566+
assert type(exc.value) is type(OSError(error, ""))
567+
assert exc.value.errno == error
568+
assert len(calls) == 2
569+
570+
571+
@pytest.mark.parametrize(
572+
"run_script",
573+
[
574+
pytest.param(run_python, id="python"),
575+
pytest.param(run_bash, id="bash", marks=requires_bash),
576+
],
577+
)
578+
@pytest.mark.parametrize("has_regular_plan", [False, True], ids=["alone", "with-plan"])
579+
def test_mtime_fallback_skips_inaccessible_external_symlink(
580+
tmp_path, run_script, has_regular_plan
581+
):
582+
repo = make_project(tmp_path / "project", context_file="AGENTS.md")
583+
expected_plan = ""
584+
if has_regular_plan:
585+
expected_plan = "specs/001-valid/plan.md"
586+
plan = repo / expected_plan
587+
plan.parent.mkdir(parents=True)
588+
plan.write_text("# plan\n", encoding="utf-8")
589+
private = tmp_path / "private"
590+
private.mkdir()
591+
target = private / "plan.md"
592+
target.write_text("# external plan\n", encoding="utf-8")
593+
candidate = repo / "specs/999-candidate/plan.md"
594+
candidate.parent.mkdir(parents=True)
595+
try:
596+
candidate.symlink_to(target)
597+
except (OSError, NotImplementedError) as exc:
598+
pytest.skip(f"symlinks unavailable: {exc}")
599+
original_mode = private.stat().st_mode
600+
try:
601+
private.chmod(0)
602+
try:
603+
candidate.stat()
604+
except PermissionError as exc:
605+
assert exc.errno == errno.EACCES
606+
else:
607+
pytest.skip("directory permissions do not cause EACCES for this user/filesystem")
608+
result = run_script(repo)
609+
finally:
610+
private.chmod(original_mode)
611+
612+
assert result.returncode == 0, result.stderr
613+
assert result.stdout == "agent-context: updated AGENTS.md\n"
614+
assert result.stderr == ""
615+
expected = (
616+
"<!-- SPECKIT START -->\n"
617+
"For additional context about technologies to be used, project structure,\n"
618+
"shell commands, and other important information, read the current plan\n"
619+
)
620+
if expected_plan:
621+
expected += f"at {expected_plan}\n"
622+
expected += "<!-- SPECKIT END -->\n"
623+
assert (repo / "AGENTS.md").read_bytes() == expected.encode("utf-8")
624+
625+
346626
@requires_posix_bash
347627
def test_python_mtime_fallback_skips_plan_reached_through_escaping_symlink(
348628
tmp_path: Path,

0 commit comments

Comments
 (0)