import os import sys import time import tempfile import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from wflib import check as K from wflib import config as C TOML = 'format = 1\ntasks = "TASKS.md"\narchive = "tasks/archive.md"\ndocs = ["DESIGN.md", "docs/"]\n' ANCHORS = '[anchors]\nindex = "DESIGN.md"\nindex_section = "Subsystems"\nspecs = "docs/specs"\n' ARCHIVE = "# Archive (newest first)\n\n- 2026-09-01 **t-done** Done thing — ok\n" DESIGN = """\ # Design ## Subsystems ### terrain Heightmap. [spec](docs/specs/terrain.md#terrain) ### input Keys. ## Notes Free text. """ SPEC = '# Terrain spec\n\n\n## Terrain\n\nText.\n' CLEAN = """\ # Tasks ## Awaiting your decision - **a-key**: Key needed. Which one? ## Pending - **t-one** [P1] (1h): One. - After: [[t-done]] Ref: DESIGN.md#terrain, docs/specs/terrain.md - **t-two** [P2] (5h) (blocked: [[a-key]]): Two. - After: [[t-one]] ## Needs human ## Deferred """ class Base(unittest.TestCase): toml = TOML def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name).resolve() (self.root / "tasks").mkdir() (self.root / "docs" / "specs").mkdir(parents=True) (self.root / "workflow.toml").write_text(self.toml) (self.root / "tasks" / "archive.md").write_text(ARCHIVE) (self.root / "DESIGN.md").write_text(DESIGN) (self.root / "docs" / "specs" / "terrain.md").write_text(SPEC) self.tasks(CLEAN) def tearDown(self): self.tmp.cleanup() def tasks(self, text): (self.root / "TASKS.md").write_text(text) def pending(self, items): self.tasks("## Awaiting your decision\n\n- **a-key**: Key. Q?\n\n## Pending\n\n" + items + "\n## Needs human\n\n## Deferred\n") def run_check(self): errors, warnings = K.check(C.load(self.root)) return [str(e) for e in errors], [str(w) for w in warnings] def errors(self): return self.run_check()[0] class TasksCheckTest(Base): def test_clean(self): self.assertEqual(self.run_check(), ([], [])) def test_after_prose_and_deferred(self): self.tasks("## Awaiting your decision\n\n## Pending\n\n" "- **t-a** [P1] (1h): A.\n After: when ready [[t-d]]\n\n" "## Needs human\n\n## Deferred\n\n- **t-d** [P3] (1h): D.\n") w = self.run_check()[1] self.assertEqual(len(w), 2, w) self.assertTrue(any("has prose" in x and ":6:" in x for x in w), w) self.assertTrue(any("'t-d' is deferred" in x for x in w), w) def test_after_clean_links(self): self.pending("- **t-a** [P1] (1h): A.\n After: [[t-done]], [[t-b]]\n\n- **t-b** [P1] (1h): B.\n") self.assertEqual(self.run_check()[1], []) def test_duplicate_id(self): self.pending("- **t-a** [P1] (1h): A.\n\n- **t-a** [P1] (1h): Again.\n") self.assertEqual(self.errors(), ["TASKS.md:9: t-a: duplicate id (also line 7)"]) def test_id_reused_from_archive(self): self.pending("- **t-done** [P1] (1h): A.\n") self.assertEqual(self.errors(), ["TASKS.md:7: t-done: id already used in the archive (ids are never reused)"]) def test_bad_grammar(self): self.pending("- **T-Foo** [P1] (1h): A.\n") self.assertEqual(self.errors(), ["TASKS.md:7: T-Foo: bad id (want t-… or a-…, lowercase a-z 0-9 -)"]) def test_wrong_kind_for_section(self): self.pending("- **a-x**: A. Q?\n") self.assertEqual(self.errors(), ["TASKS.md:7: a-x: a- item in Pending (a- items belong in Awaiting, t- elsewhere)"]) def test_task_in_awaiting(self): self.tasks("## Awaiting your decision\n\n- **t-x** [P1] (1h): A.\n\n## Pending\n\n## Needs human\n\n## Deferred\n") self.assertEqual(self.errors(), ["TASKS.md:3: t-x: t- item in Awaiting your decision (a- items belong in Awaiting, t- elsewhere)"]) def test_missing_priority_and_effort(self): self.pending("- **t-a**: A.\n") self.assertEqual(self.errors(), ["TASKS.md:7: t-a: no priority [P0]-[P3]", "TASKS.md:7: t-a: no effort (want <1h, 1h, 5h, 10h, 100h)"]) def test_bad_priority_and_effort(self): self.pending("- **t-a** [P7] (2h): A.\n") self.assertEqual(self.errors(), ["TASKS.md:7: t-a: priority P7 (want P0-P3)", "TASKS.md:7: t-a: effort '2h' (want <1h, 1h, 5h, 10h, 100h)"]) def test_dangling_link(self): self.pending("- **t-a** [P1] (1h): A.\n - see [[t-none]]\n") self.assertEqual(self.errors(), ["TASKS.md:8: [[t-none]]: no such id in TASKS or archive"]) def test_dangling_link_in_prose_section(self): self.tasks(CLEAN + "\n## Notes\n\nSee [[a-gone]] and `[[t-example]]`.\n") self.assertEqual(self.errors(), ["TASKS.md:22: [[a-gone]]: no such id in TASKS or archive"]) def test_blocked_on_missing_awaiting_item(self): self.pending("- **t-a** [P1] (1h) (blocked: [[a-none]]): A.\n") self.assertEqual(self.errors(), ["TASKS.md:7: [[a-none]]: no such id in TASKS or archive", "TASKS.md:7: t-a: blocked on 'a-none', which is not an open Awaiting item"]) def test_after_cycle(self): self.pending("- **t-a** [P1] (1h): A.\n - After: [[t-b]]\n\n- **t-b** [P1] (1h): B.\n - After: [[t-a]]\n") self.assertIn("TASKS.md:7: t-a: After: cycle t-a → t-b → t-a", self.errors()) def test_item_before_its_open_dependency(self): self.pending("- **t-a** [P1] (1h): A.\n - After: [[t-b]]\n\n- **t-b** [P1] (1h): B.\n") self.assertEqual(self.errors(), ["TASKS.md:7: t-a: placed before 't-b', which it is After:"]) def test_after_bare_id_is_flagged(self): self.pending("- **t-a** [P1] (1h): A.\n After: t-b\n\n- **t-b** [P1] (1h): B.\n") self.assertEqual(self.errors(), ["TASKS.md:8: t-a: After: 't-b' is not a link (want [[t-b]])"]) def test_slices_bare_id_is_flagged(self): self.pending("- **t-a** [P1] (1h): A.\n - Slices: [[t-b]], t-c\n\n- **t-b** [P1] (1h): B.\n") self.assertEqual(self.errors(), ["TASKS.md:8: t-a: Slices: 't-c' is not a link (want [[t-c]])"]) def test_ref_path_missing(self): self.pending("- **t-a** [P1] (1h): A.\n Ref: docs/none.md\n") self.assertEqual(self.errors(), ["TASKS.md:8: t-a: Ref 'docs/none.md' does not exist"]) def test_ref_anchor_missing(self): self.pending("- **t-a** [P1] (1h): A.\n Ref: DESIGN.md#nope\n") self.assertEqual(self.errors(), ["TASKS.md:8: t-a: Ref 'DESIGN.md#nope': no such anchor"]) def test_old_numbered_items(self): self.pending("1. **[P1] Old** (Effort: 1h) — goal.\n - Steps\n") self.assertEqual(self.errors(), ["TASKS.md:7: old numbered item (wf migrate)"]) def test_malformed_header_has_line(self): self.pending("- **t-ok** [P1] (1h): Fine.\n\n- **t-x** [P1] (1h) no colon\n") self.assertEqual(self.errors(), ["TASKS.md:9: t-x: bad header: want '- **id** [Pn] (effort) [(status)]: Title. Goal.'"]) def test_item_after_prose_is_flagged(self): self.pending("- **t-a** [P1] (1h): A.\nstray\n- **t-b** [P1] (1h): B.\n") self.assertEqual(self.errors(), ["TASKS.md:9: t-b: item after a flush-left prose line (line 8): indent or move the prose"]) def test_missing_section(self): self.tasks("## Pending\n\n## Needs human\n\n## Deferred\n") self.assertEqual(self.errors(), ["TASKS.md: no '## Awaiting your decision' section"]) def test_number_refs_warn(self): self.pending("- **t-a** [P1] (1h): A, see #12.\n") self.assertEqual(self.run_check(), ([], ["TASKS.md:7: '#12': number ref (tasks have ids: [[t-…]])"])) def test_empty_progress_note_warns(self): self.pending("- **t-a** [P1] (1h) (in progress: ): A.\n") self.assertEqual(self.run_check(), ([], ["TASKS.md:7: t-a: in progress without a branch or note"])) def test_doc_link_to_unknown_id(self): (self.root / "docs" / "note.md").write_text("# N\n\nSee [[t-one]], [[t-done]], [[t-lost]], [[wiki-page]].\n") self.assertEqual(self.errors(), ["docs/note.md:3: [[t-lost]]: no such id in TASKS or archive"]) class StaleAwaitingTest(Base): def commit(self, date): import os import subprocess env = {**os.environ, "GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"} for cmd in (["init", "-q"], ["add", "-A"], ["commit", "-q", "-m", "x"]): subprocess.run(["git", "-C", str(self.root), *cmd], check=True, env=env, capture_output=True) def test_old_unreferenced_awaiting_item_warns(self): self.tasks(CLEAN.replace("- **a-key**: Key needed. Which one?", "- **a-key**: Key needed. Which one?\n\n- **a-old**: Old question. Still open?")) self.commit("2020-01-01T00:00:00") errors, warnings = self.run_check() self.assertEqual(errors, []) self.assertEqual(len(warnings), 1) self.assertRegex(warnings[0], r"^TASKS.md:7: a-old: waiting \d+ days, no task references it$") def test_fresh_item_does_not_warn(self): self.tasks(CLEAN.replace("- **a-key**: Key needed. Which one?", "- **a-key**: Key needed. Which one?\n\n- **a-new**: New question. Open?")) import datetime self.commit(datetime.datetime.now().isoformat(timespec="seconds")) self.assertEqual(self.run_check(), ([], [])) class ConfigCheckTest(Base): def test_missing_tasks_file(self): (self.root / "TASKS.md").unlink() self.assertEqual(self.errors(), ["workflow.toml: tasks 'TASKS.md' does not exist"]) def test_missing_archive_and_docs(self): (self.root / "tasks" / "archive.md").unlink() (self.root / "DESIGN.md").unlink() self.pending("- **t-a** [P1] (1h): A.\n") self.assertEqual(self.errors(), ["workflow.toml: archive 'tasks/archive.md' does not exist", "workflow.toml: docs 'DESIGN.md' does not exist"]) class AnchorsCheckTest(Base): toml = TOML + ANCHORS def test_clean(self): self.assertEqual(self.run_check(), ([], [])) def test_task_anchor_not_in_index_section(self): self.pending("- **t-a** [P1] (1h): A.\n Ref: DESIGN.md#notes\n") self.assertEqual(self.errors(), ["TASKS.md:8: t-a: Ref 'DESIGN.md#notes' is not a heading under 'Subsystems'"]) def test_duplicate_index_slug(self): (self.root / "DESIGN.md").write_text(DESIGN.replace("### input", "### Terrain")) self.assertEqual(self.errors(), ["DESIGN.md:9: two 'Subsystems' headings give anchor 'terrain' (also line 5)"]) def test_index_link_to_missing_file_and_anchor(self): (self.root / "DESIGN.md").write_text(DESIGN.replace("Keys.", "Keys. [a](docs/none.md) [b](docs/specs/terrain.md#nope) [c](https://x.y/z)")) self.assertEqual(self.errors(), ["DESIGN.md:11: link 'docs/none.md': file does not exist", "DESIGN.md:11: link 'docs/specs/terrain.md#nope': no such anchor"]) def test_explicit_spec_anchor_without_index_entry(self): (self.root / "docs" / "specs" / "combat.md").write_text('# Combat\n\n\n## Combat\n') self.assertEqual(self.errors(), ["docs/specs/combat.md:3: explicit anchor 'combat' has no heading under 'Subsystems' in DESIGN.md"]) class ProblemTest(unittest.TestCase): def test_key_ignores_line(self): a = K.Problem("TASKS.md", 7, "t-a", "x") b = K.Problem("TASKS.md", 9, "t-a", "x") self.assertEqual(a.key, b.key) self.assertEqual(str(a), "TASKS.md:7: t-a: x") self.assertEqual(str(K.Problem("workflow.toml", None, "", "y")), "workflow.toml: y") if __name__ == "__main__": unittest.main() class MergedToolWorktreeTest(unittest.TestCase): def git(self, root, *a): import subprocess subprocess.run(["git", "-C", str(root), "-c", "user.name=t", "-c", "user.email=t@t", *a], check=True, capture_output=True) def test_flags_only_merged_worktrees(self): with tempfile.TemporaryDirectory() as d: root = Path(d) / "tool" root.mkdir() self.git(root, "init", "-b", "master") self.git(root, "commit", "--allow-empty", "-m", "a") self.git(root, "worktree", "add", str(Path(d) / "fresh"), "-b", "fresh") self.git(root, "worktree", "add", str(Path(d) / "done"), "-b", "done") self.git(root, "worktree", "add", str(Path(d) / "open"), "-b", "open") self.git(Path(d) / "open", "commit", "--allow-empty", "-m", "b") self.git(root, "commit", "--allow-empty", "-m", "c") old = time.time() - 3 * 3600 for n in ("done", "open"): os.utime(Path(d) / n / ".git", (old, old)) got = K.merged_tool_worktrees(root) names = sorted(p.message.split("branch ")[1].split(")")[0] for p in got) self.assertEqual(names, ["done"]) # fresh (<2h) skipped def test_not_git(self): with tempfile.TemporaryDirectory() as d: self.assertEqual(K.merged_tool_worktrees(Path(d)), [])