|
9 | 9 |
|
10 | 10 | from __future__ import annotations |
11 | 11 |
|
| 12 | +import errno |
| 13 | +import io |
12 | 14 | import json |
13 | 15 | import os |
| 16 | +import re |
| 17 | +import runpy |
14 | 18 | import shutil |
15 | 19 | import subprocess |
16 | 20 | import sys |
17 | 21 | import time |
| 22 | +from contextlib import redirect_stdout |
18 | 23 | from pathlib import Path |
19 | 24 |
|
20 | 25 | import pytest |
21 | 26 |
|
| 27 | +from tests.conftest import requires_bash |
22 | 28 | from tests.extensions.test_extension_agent_context import ( |
23 | 29 | BASH, |
24 | 30 | EXT_DIR, |
25 | 31 | POWERSHELL, |
| 32 | + _bash_posix_path, |
26 | 33 | _bundled_script_env, |
27 | 34 | ) |
28 | 35 |
|
|
38 | 45 |
|
39 | 46 | def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess: |
40 | 47 | return subprocess.run( |
41 | | - [BASH, str(BASH_SCRIPT), *args], |
| 48 | + [BASH, _bash_posix_path(BASH_SCRIPT), *args], |
42 | 49 | cwd=project_root, |
43 | 50 | env=_bundled_script_env(project_root, for_bash=True), |
44 | 51 | capture_output=True, |
@@ -343,6 +350,279 @@ def test_python_mtime_fallback_finds_nested_plan_matching_bash( |
343 | 350 | assert b"at specs/backend/001-nested/plan.md" in content |
344 | 351 |
|
345 | 352 |
|
| 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 | + |
346 | 626 | @requires_posix_bash |
347 | 627 | def test_python_mtime_fallback_skips_plan_reached_through_escaping_symlink( |
348 | 628 | tmp_path: Path, |
|
0 commit comments