workflow

git clone https://git.godosa.eu/workflow

master

raw · 13764 bytes

import subprocess
import tempfile
import unittest
from pathlib import Path

from test_cli import Cli
from test_claims import git
from test_merge import IDENT
from wflib import areas  # noqa: E402  (tests/ run with the repo root on sys.path)

NOTES = """\
# CLAUDE.md

## Areas
### Parser
- Code map: `parse_item`, `NOPE_GONE`
- Test recipe: python3 -m unittest tests.test_p
- Paths: src/
- Checked: {sha}

### Docs
- Code map: `intro`

## Gotchas
- x
"""


def build(root: Path) -> tuple[str, str]:
    """Repo with src/p.py + docs.md, then 3 commits touching src/; returns (sha1, notes)."""
    git(root, "init", "-q", "-b", "master")
    (root / "src").mkdir(exist_ok=True)
    (root / "src" / "p.py").write_text("def parse_item(): pass\n")
    (root / "docs.md").write_text("intro\n")
    (root / ".gitignore").write_text(".wf/\n")
    git(root, "add", "-A")
    git(root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1")
    sha1 = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=root, capture_output=True, text=True).stdout.strip()
    for n in "abc":
        (root / "src" / f"{n}.txt").write_text(n)
        git(root, "add", "-A")
        git(root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", n)
    return sha1, NOTES.format(sha=sha1)


class AreasTest(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name).resolve()
        self.sha1, self.notes = build(self.root)

    def tearDown(self):
        self.tmp.cleanup()

    def test_parse(self):
        a = areas.parse(NOTES.format(sha="abc1234"))
        self.assertEqual([(x.name, x.slug, x.anchors, x.paths, x.checked) for x in a], [
            ("Parser", "parser", ["parse_item", "NOPE_GONE"], ["src/"], "abc1234"),
            ("Docs", "docs", ["intro"], [], None)])

    def test_missing_and_commits(self):
        parser, docs = areas.parse(self.notes)
        self.assertEqual(areas.missing(self.root, parser), ["NOPE_GONE"])
        self.assertEqual(areas.commits_since(self.root, parser), 3)
        self.assertEqual(areas.missing(self.root, docs), [])
        self.assertIsNone(areas.commits_since(self.root, docs))

    def test_stale(self):
        parser, docs = areas.parse(self.notes)
        self.assertEqual(areas.status(self.root, parser, 20), (True, ["NOPE_GONE"], 3))
        self.assertEqual(areas.status(self.root, docs, 20), (False, [], None))
        fixed = areas.parse(self.notes.replace(", `NOPE_GONE`", ""))[0]
        self.assertEqual(areas.status(self.root, fixed, 3), (True, [], 3))
        self.assertEqual(areas.status(self.root, fixed, 4), (False, [], 3))

    def test_unknown_checked_sha(self):
        a = areas.parse(NOTES.format(sha="deadbee"))[0]
        self.assertIsNone(areas.commits_since(self.root, a))

    def test_mark(self):
        out = areas.mark(NOTES.format(sha="abc1234"), "Docs", "f00ba44")
        self.assertIn("### Docs\n- Code map: `intro`\n- Checked: f00ba44\n\n## Gotchas", out)
        out = areas.mark(out, "Parser", "1111111")
        self.assertIn("- Paths: src/\n- Checked: 1111111\n", out)


class MatchTest(unittest.TestCase):
    def test_matches(self):
        parser, docs = areas.parse(NOTES.format(sha="abc1234"))
        self.assertTrue(areas.matches(parser, "Fix src/p.py crash"))
        self.assertTrue(areas.matches(parser, "see x.py/src/p.py"))
        self.assertTrue(areas.matches(parser, "parse_item() loops"))
        self.assertTrue(areas.matches(parser, "the parser area"))
        self.assertFalse(areas.matches(parser, "parse_items and srcs"))
        self.assertFalse(areas.matches(docs, "introduction"))
        self.assertTrue(areas.matches(docs, "docs intro"))
        self.assertFalse(areas.matches(parser, "Fix src/p.py crash", {"src"}))

    def test_shared_paths(self):
        a = areas.parse("## Areas\n### A\n- Paths: x.py lib/\n### B\n- Paths: ./x.py y.py\n### C\n- Paths: lib\n")
        self.assertEqual(areas.shared_paths(a), {"x.py", "lib"})

    def test_block(self):
        text = NOTES.format(sha="abc1234")
        self.assertEqual(areas.block(text, "Docs"), "### Docs\n- Code map: `intro`")
        self.assertEqual(areas.block(text, "Parser").splitlines()[-1], "- Checked: abc1234")
        self.assertEqual(areas.block(text, "X"), "")


class CodeRootTest(Cli):
    """code_root: area anchors / Checked / staleness live in another repo than the project."""

    def setUp(self):
        super().setUp()
        self.code = self.root.parent / "code"
        self.code.mkdir()
        self.sha1, notes = build(self.code)
        (self.root / "CLAUDE.md").write_text(notes)
        with open(self.root / "workflow.toml", "a") as f:
            f.write('code_root = "../code"\n')
        git(self.root, "init", "-q", "-b", "master")
        (self.root / "engine").mkdir()
        (self.root / "engine" / "x.py").write_text("x")

    def test_areas_use_code_root(self):
        self.assertEqual(self.ok("areas"), f"Parser: stale · missing: NOPE_GONE · 3 commits since {self.sha1}\n"
                                           "Docs: ok · no Checked\n")

    def test_mark_uses_code_head(self):
        self.ok("areas", "--mark", "Docs")
        head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=self.code, capture_output=True,
                              text=True).stdout.strip()
        self.assertIn(f"- Checked: {head}", (self.root / "CLAUDE.md").read_text())

    def test_check_uses_code_root(self):
        out = self.ok("check")
        self.assertIn("area Parser: anchor NOPE_GONE not found", out)
        self.assertNotIn("Checked", out)
        self.assertNotIn("anchor intro", out)

    def test_no_uncovered_nudge(self):
        self.assertNotIn("uncovered", self.ok("areas"))


class CtxAreaTest(Cli):
    tasks_text = ("# T\n\n## Pending\n\n- **t-a** [P1] (<1h): A. Fix src/p.py.\n\n"
                  "- **t-b** [P1] (<1h): B. Unrelated.\n")

    def setUp(self):
        super().setUp()
        (self.root / "CLAUDE.md").write_text(NOTES.format(sha="abc1234"))

    def test_ctx_prints_matching_area(self):
        out = self.ok("ctx", "t-a")
        self.assertIn("\nArea Parser (CLAUDE.md):\n### Parser\n- Code map: `parse_item`, `NOPE_GONE`\n"
                      "- Test recipe: python3 -m unittest tests.test_p\n", out)
        self.assertNotIn("### Docs", out)

    def test_ctx_no_match(self):
        self.assertNotIn("Area ", self.ok("ctx", "t-b"))

    def test_ctx_shared_path_no_match(self):
        (self.root / "CLAUDE.md").write_text(NOTES.format(sha="abc1234").replace("- Code map: `intro`",
                                                                                 "- Code map: `intro`\n- Paths: src/"))
        self.assertNotIn("Area ", self.ok("ctx", "t-a"))


class AreasCliTest(Cli):
    def setUp(self):
        super().setUp()
        sha1, notes = build(self.root)
        self.sha1 = sha1
        (self.root / "CLAUDE.md").write_text(notes)

    def test_areas_output(self):
        out = self.ok("areas")
        self.assertEqual(out, f"Parser: stale · missing: NOPE_GONE · 3 commits since {self.sha1}\n"
                              "Docs: ok · no Checked\n")

    def test_mark_then_show(self):
        self.ok("areas", "--mark", "Docs")
        head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=self.root, capture_output=True,
                              text=True).stdout.strip()
        self.assertEqual(self.ok("areas", "Docs"), f"Docs: ok · 0 commits since {head}\n")

    def test_mark_unique_prefix(self):
        self.ok("areas", "--mark", "Do")
        head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=self.root, capture_output=True,
                              text=True).stdout.strip()
        self.assertEqual(self.ok("areas", "Doc"), f"Docs: ok · 0 commits since {head}\n")

    def test_ambiguous_prefix(self):
        txt = (self.root / "CLAUDE.md").read_text().replace("### Docs", "### Parser2 (x)")
        (self.root / "CLAUDE.md").write_text(txt)
        err = self.fails("areas", "--mark", "Par", code=2)
        self.assertIn("ambiguous area 'Par'", err)
        self.assertIn("Parser, Parser2 (x)", err)
        self.ok("areas", "--mark", "Parser")

    def test_unknown_area(self):
        err = self.fails("areas", "X", code=2)
        self.assertIn("wf: no area 'X' in CLAUDE.md (Parser, Docs)", err)

    def test_check_warnings(self):
        out = self.ok("check")
        self.assertIn("area Parser: anchor NOPE_GONE not found", out)
        (self.root / "CLAUDE.md").write_text(NOTES.format(sha="deadbee").replace("### Parser", "### X"))
        self.assertIn("area X: Checked deadbee unknown", self.ok("check"))


class AutoTaskTest(Cli):
    tasks_text = "# T\n\n## Pending\n\n- **t-a** [P1] (<1h): A. Do.\n\n- **t-b** [P1] (<1h): B. Do.\n"

    def setUp(self):
        super().setUp()
        sha1, notes = build(self.root)
        (self.root / "CLAUDE.md").write_text(notes)

    def test_auto_task(self):
        out = self.ok("done", "t-a", "-m", "x")
        self.assertIn("added t-map-parser (area map stale)", out)
        shown = self.ok("show", "t-map-parser")
        self.assertIn("- **t-map-parser** [P1] (<1h): Refresh Parser area map.", shown)
        self.assertIn("Steps: wf areas Parser; fix missing anchors + test recipe from git log --stat "
                      "<Checked>..HEAD -- <paths>; wf areas --mark Parser.", shown)
        self.assertIn("Done: wf areas Parser shows ok.", shown)
        self.assertIn("Model: sonnet", shown)

    def test_auto_task_once(self):
        self.ok("done", "t-a", "-m", "x")
        out = self.ok("done", "t-b", "-m", "y")
        self.assertNotIn("added", out)
        self.assertEqual(self.ok("list").count("t-map-parser"), 1)

    def test_archived_gets_suffix(self):
        self.ok("done", "t-a", "-m", "x")
        out = self.ok("done", "t-map-parser", "-m", "z")
        self.assertIn("added t-map-parser-2 (area map stale)", out)

    def test_no_areas_file(self):
        (self.root / "CLAUDE.md").unlink()
        self.assertNotIn("added", self.ok("done", "t-a", "-m", "x"))


class UncoveredTest(unittest.TestCase):
    def test_uncovered_groups_two_levels(self):
        a = areas.parse(NOTES.format(sha="abc1234"))
        files = ["src/p.py", "lib/x/y/z.py", "lib/x/w.py", "lib/q.py", "README.md", "tests/t.py",
                 ".github/ci.yml", "TASKS.md"]
        self.assertEqual(areas.uncovered(files, a, ["tests"]), [("lib/x", 2), ("lib", 1)])

    def test_paths_globs_and_files(self):
        a = areas.parse("## Areas\n### A\n- Paths: app/*.py tools/run.sh\n")
        self.assertEqual(areas.uncovered(["app/m.py", "tools/run.sh", "tools/other.sh"], a, []), [("tools", 1)])

    def test_no_paths_anywhere_no_nudge(self):
        a = areas.parse("## Areas\n### A\n- Code map: `x`\n")
        self.assertEqual(areas.uncovered(["lib/q.py"], a, []), [])


class AnchorPathsTest(unittest.TestCase):
    def test_anchor_folders_stand_in(self):
        with tempfile.TemporaryDirectory() as d:
            root = Path(d)
            build(root)
            a = areas.parse("## Areas\n### A\n- Code map: `parse_item`, `intro`\n### B\n- Paths: lib/\n")
            self.assertEqual(areas.with_anchor_paths(root, a[0]).paths, ["docs.md", "src"])
            self.assertEqual(areas.with_anchor_paths(root, a[1]).paths, ["lib/"])


class UncoveredCliTest(Cli):
    tasks_text = "# T\n\n## Pending\n\n- **t-a** [P1] (<1h): A. Do.\n\n- **t-b** [P1] (<1h): B. Do.\n"

    def setUp(self):
        super().setUp()
        build(self.root)
        notes = NOTES.format(sha="x").replace(", `NOPE_GONE`", "").replace("- Checked: x\n", "")
        (self.root / "CLAUDE.md").write_text(notes)
        git(self.root, "add", "-A")
        git(self.root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "notes")
        (self.root / "engine" / "net").mkdir(parents=True)
        (self.root / "engine" / "net" / "sock.py").write_text("x")
        (self.root / "docs.md").write_text("changed")

    def test_done_adds_map_task(self):
        out = self.ok("done", "t-a", "-m", "x")
        self.assertIn("added t-map-engine-net (uncovered area: engine/net, 1 file)", out)
        shown = self.ok("show", "t-map-engine-net")
        self.assertIn("- **t-map-engine-net** [P1] (<1h): Map engine/net area. "
                      "No area's Paths covers it.", shown)
        self.assertIn("Done: wf areas lists the area ok and no longer names engine/net uncovered.", shown)
        self.assertIn("Model: sonnet", shown)
        self.assertNotIn("added", self.ok("done", "t-b", "-m", "y"))

    def test_areas_names_uncovered(self):
        self.assertIn("uncovered: engine/net (1 file)", self.ok("areas"))

    def test_branch_diff_counts(self):
        git(self.root, "switch", "-qc", "topic")
        git(self.root, "add", "-A")
        git(self.root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "work")
        self.assertIn("uncovered: engine/net (1 file)", self.ok("areas"))

    def test_no_paths_uses_anchor_folders(self):
        (self.root / "CLAUDE.md").write_text("## Areas\n### P\n- Code map: `parse_item`\n")
        (self.root / "src" / "n.py").write_text("x")
        out = self.ok("areas")
        self.assertIn("uncovered: engine/net (1 file)", out)
        self.assertNotIn("uncovered: src", out)

    def test_ignore_config(self):
        with open(self.root / "workflow.toml", "a") as f:
            f.write('area_ignore = ["engine"]\n')
        self.assertNotIn("uncovered", self.ok("areas"))
        self.assertNotIn("added", self.ok("done", "t-a", "-m", "x"))


if __name__ == "__main__":
    unittest.main()