Files
2026-07-13 12:40:00 +08:00

201 lines
9.2 KiB
Python

"""Tests for scripts/resolve-plan-dir.sh — addresses #148.
Resolver order:
1. $PLAN_ID env → .planning/<id>/ if exists
2. .planning/.active_plan content → .planning/<id>/ if exists
3. Newest .planning/<dir>/ by mtime
4. Legacy fallback: <cwd>/task_plan.md exists → emit empty (caller uses cwd)
5. Otherwise empty stdout, exit 0
"""
from __future__ import annotations
import os
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RESOLVE_SH = REPO_ROOT / "scripts" / "resolve-plan-dir.sh"
class ResolvePlanDirTests(unittest.TestCase):
def run_resolver(self, cwd: Path, plan_id: str | None = None) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env.pop("PLAN_ID", None)
if plan_id is not None:
env["PLAN_ID"] = plan_id
return subprocess.run(
["sh", str(RESOLVE_SH)],
cwd=str(cwd),
text=True,
encoding="utf-8",
capture_output=True,
env=env,
check=False,
)
def test_resolver_script_exists(self) -> None:
self.assertTrue(RESOLVE_SH.exists(), "scripts/resolve-plan-dir.sh missing")
def test_empty_repo_returns_nothing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
result = self.run_resolver(Path(tmp))
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual("", result.stdout.strip())
def test_env_plan_id_takes_precedence(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".planning" / "alpha").mkdir(parents=True)
(root / ".planning" / "beta").mkdir(parents=True)
(root / ".planning" / ".active_plan").write_text("beta\n", encoding="utf-8")
result = self.run_resolver(root, plan_id="alpha")
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(result.stdout.strip().endswith("alpha"))
def test_active_plan_used_when_env_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".planning" / "alpha").mkdir(parents=True)
(root / ".planning" / "beta").mkdir(parents=True)
(root / ".planning" / ".active_plan").write_text("beta\n", encoding="utf-8")
result = self.run_resolver(root)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(result.stdout.strip().endswith("beta"))
def test_falls_back_to_newest_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
old = root / ".planning" / "older"
new = root / ".planning" / "newer"
old.mkdir(parents=True)
(old / "task_plan.md").write_text("# old\n", encoding="utf-8")
time.sleep(0.05)
new.mkdir(parents=True)
(new / "task_plan.md").write_text("# new\n", encoding="utf-8")
# bump mtime explicitly to be safe across filesystems
os.utime(new, None)
result = self.run_resolver(root)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(
result.stdout.strip().endswith("newer"),
f"expected newer, got {result.stdout!r}",
)
def test_legacy_root_plan_emits_empty(self) -> None:
# When no .planning/ but cwd/task_plan.md exists, resolver emits empty so
# callers fall back to the legacy root path. This preserves v1.x users.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "task_plan.md").write_text("# legacy\n", encoding="utf-8")
result = self.run_resolver(root)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual("", result.stdout.strip())
def test_env_plan_id_pointing_to_missing_dir_falls_through(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
real = root / ".planning" / "real"
real.mkdir(parents=True)
(real / "task_plan.md").write_text("# real plan\n", encoding="utf-8")
result = self.run_resolver(root, plan_id="ghost")
self.assertEqual(0, result.returncode, result.stderr)
# Should fall through to newest existing plan dir
self.assertTrue(result.stdout.strip().endswith("real"))
def test_corrupt_active_plan_whitespace_only_falls_through(self) -> None:
# Regression for v2.40: .active_plan filled with whitespace/newlines
# used to be normalized to an empty string by `tr -d`, leaving the
# resolver about to look up `.planning//task_plan.md`. The slug-validity
# check now rejects empty / whitespace-only content and falls through to
# the newest-mtime resolution path.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
real = root / ".planning" / "real"
real.mkdir(parents=True)
(real / "task_plan.md").write_text("# real plan\n", encoding="utf-8")
(root / ".planning" / ".active_plan").write_text(" \n\n \n", encoding="utf-8")
result = self.run_resolver(root)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(
result.stdout.strip().endswith("real"),
f"expected fall-through to real, got {result.stdout!r}",
)
def test_corrupt_active_plan_with_path_separator_rejected(self) -> None:
# Regression for v2.40: a malicious or corrupt .active_plan containing
# a path separator (e.g. ../escape, ./.planning/foo) used to be passed
# directly to the candidate path, opening a path-traversal-shaped
# surface. The slug-validity check now rejects any plan-id with `/`,
# `..`, or leading-dot, falling through to newest-mtime.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
real = root / ".planning" / "real"
real.mkdir(parents=True)
(real / "task_plan.md").write_text("# real plan\n", encoding="utf-8")
(root / ".planning" / ".active_plan").write_text("../escape\n", encoding="utf-8")
result = self.run_resolver(root)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(
result.stdout.strip().endswith("real"),
f"expected fall-through to real, got {result.stdout!r}",
)
def test_env_plan_id_with_whitespace_rejected(self) -> None:
# Regression for v2.40: PLAN_ID env with whitespace or empty value must
# not bypass the slug check. Falls through cleanly.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
real = root / ".planning" / "real"
real.mkdir(parents=True)
(real / "task_plan.md").write_text("# real plan\n", encoding="utf-8")
result = self.run_resolver(root, plan_id=" ")
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(result.stdout.strip().endswith("real"))
def test_latest_dir_scan_skips_invalid_slug_names(self) -> None:
# Regression for v2.40: dirs with non-safe names (path traversal,
# dotfiles, leading whitespace) must be skipped by the newest-mtime
# scan so a malicious .planning/..foo/ cannot win the resolution.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = root / ".planning" / "good"
good.mkdir(parents=True)
(good / "task_plan.md").write_text("# good\n", encoding="utf-8")
# A dot-prefixed dir; should be skipped by both the slug check
# AND the case .*) continue ;; guard.
sneaky = root / ".planning" / ".sneaky"
sneaky.mkdir(parents=True)
(sneaky / "task_plan.md").write_text("# sneaky\n", encoding="utf-8")
result = self.run_resolver(root)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(
result.stdout.strip().endswith("good"),
f"expected good, got {result.stdout!r}",
)
def test_dead_active_plan_target_falls_through(self) -> None:
# Regression for v2.40: .active_plan points to a dir that has been
# deleted. Resolver must fall through to newest-existing instead of
# printing the dead path.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
real = root / ".planning" / "real"
real.mkdir(parents=True)
(real / "task_plan.md").write_text("# real\n", encoding="utf-8")
(root / ".planning" / ".active_plan").write_text("deleted-plan\n", encoding="utf-8")
# Note: .planning/deleted-plan/ never created
result = self.run_resolver(root)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(
result.stdout.strip().endswith("real"),
f"expected real, got {result.stdout!r}",
)
if __name__ == "__main__":
unittest.main()