fix: reconcile the feature-container convention with the depends validator
`format.md` told a child issue to link back to its container through its own `depends:`, while the validator wanted the container to list its children. Satisfying both made a cycle, caught as an ERROR, so every `type/feature` with a filled `## Issues` ended in either a warning or a hard failure — no third option. Variant B is chosen: the container depends on its children, and a child never names its container. "The container is closed when its children are closed" IS a dependency relation, so it belongs in the graph; "a child belongs to a feature" is membership, and membership does not. The code already walked the edge that way — `## Issues` is an edge source pointing container -> child — so this rewrites the documentation to match instead of inverting the graph, and the tree draws containers as roots for free. - format.md: the `type/feature` template states the direction, shows the container's `depends:`, and says why the reverse cycles; the Dependencies section names `## Issues` as the second edge source. - issue.py: `body_dep_ref_sections()` carries the section each reference came from, so the desync warning names `## Issues` on a container rather than a `## Depends on` that is not in the file. `body_dep_refs()` stays as a thin wrapper — `skills/sync/scripts/map.py` calls it and is untouched. - tests/test_container_edges.py: the repo's first tests. Stdlib unittest, `python3 -m unittest discover -s tests`. issue_check.py's cycle detector and issue_tree.py need no change: with the edge pointing down there is no cycle to break and the container is already the root. Refs claude-skills/tea#14 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The container <-> child edge points ONE way: container -> child.
|
||||
|
||||
A `type/feature` is closed when its children are closed, and that is a
|
||||
dependency relation, so the container lists its children in `depends:`. A child
|
||||
belongs to a feature, which is a membership relation, and membership has no
|
||||
place in a dependency graph — so a child never names its container back. These
|
||||
tests pin that direction down in all three places it shows up: the validator,
|
||||
the desync warning, and the drawn tree.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib only, like the scripts under test. `skills/*/scripts/` are directories,
|
||||
not packages, so they go on sys.path by hand.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
import issue # noqa: E402
|
||||
import issue_check # noqa: E402
|
||||
import issue_new # noqa: E402
|
||||
import issue_tree # noqa: E402
|
||||
|
||||
|
||||
def run(module, argv):
|
||||
"""Call a script's main() with argv, returning (exit_code, stdout).
|
||||
|
||||
stderr is swallowed: issue_new.py notes on it when a `--depends` id is not
|
||||
in the store yet, which is fine and not what these tests are about."""
|
||||
buf = io.StringIO()
|
||||
old = sys.argv
|
||||
sys.argv = [module.__name__ + ".py"] + argv
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
|
||||
code = module.main()
|
||||
except SystemExit as e: # argparse / sys.exit("msg")
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
finally:
|
||||
sys.argv = old
|
||||
return (code or 0), buf.getvalue()
|
||||
|
||||
|
||||
def drawn(tree_output):
|
||||
"""The rows inside the tree's code fence, header and blanks dropped."""
|
||||
return [l for l in tree_output.splitlines() if l.rstrip().endswith(".md")]
|
||||
|
||||
|
||||
class StoreCase(unittest.TestCase):
|
||||
"""A scratch store per test. Never touches tmp/issues/."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = self._tmp.name
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def new(self, type, id, title, depends=()):
|
||||
argv = ["--type", type, "--id", id, "--title", title, "--out", self.root]
|
||||
for d in depends:
|
||||
argv += ["--depends", d]
|
||||
code, _ = run(issue_new, argv)
|
||||
self.assertEqual(code, 0, "issue_new.py failed for %s" % id)
|
||||
|
||||
def edit(self, id, old, new):
|
||||
p = issue.path_of(self.root, id)
|
||||
with open(p) as f:
|
||||
text = f.read()
|
||||
self.assertIn(old, text, "%s.md does not contain %r" % (id, old))
|
||||
with open(p, "w") as f:
|
||||
f.write(text.replace(old, new))
|
||||
|
||||
def fill_issues_section(self, id, *children):
|
||||
"""Replace the type/feature template's `## Issues` placeholder."""
|
||||
self.edit(id,
|
||||
"- [ ] slug-дочернего-issue — краткое описание части\n- [ ] …\n",
|
||||
"".join("- [ ] %s — часть\n" % c for c in children))
|
||||
|
||||
def container_and_child(self):
|
||||
"""The canonical shape from references/format.md: the container names
|
||||
the child in `depends:` AND in `## Issues`; the child names nobody."""
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.new("feature", "feat-x", "Container x", depends=["child-y"])
|
||||
self.fill_issues_section("feat-x", "child-y")
|
||||
|
||||
|
||||
class CanonicalContainerIsClean(StoreCase):
|
||||
"""A container from the template plus a child per format.md: green."""
|
||||
|
||||
def test_check_is_silent_and_exits_zero(self):
|
||||
self.container_and_child()
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertNotIn("ERROR", out)
|
||||
self.assertNotIn("warn", out)
|
||||
self.assertIn("ok feat-x", out)
|
||||
self.assertIn("ok child-y", out)
|
||||
|
||||
def test_validate_reports_nothing_for_either_issue(self):
|
||||
self.container_and_child()
|
||||
issues = issue.load_all(self.root)
|
||||
for id in ("feat-x", "child-y"):
|
||||
err, warn = issue.validate(issues[id], known_ids=set(issues))
|
||||
self.assertEqual((err, warn), ([], []), id)
|
||||
|
||||
def test_the_child_does_not_depend_on_its_container(self):
|
||||
self.container_and_child()
|
||||
issues = issue.load_all(self.root)
|
||||
self.assertEqual(issues["feat-x"].depends, ["child-y"])
|
||||
self.assertEqual(issues["child-y"].depends, [])
|
||||
|
||||
|
||||
class OnlyOneDirectionIsLegal(StoreCase):
|
||||
"""format.md and the validator agree on container -> child, and the
|
||||
reverse edge is an error rather than a matter of taste."""
|
||||
|
||||
def test_the_reverse_edge_is_a_cycle(self):
|
||||
self.container_and_child()
|
||||
self.edit("child-y", "depends: []", "depends: [feat-x]")
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 1, out)
|
||||
self.assertIn("ERROR cycle:", out)
|
||||
self.assertIn("feat-x", out)
|
||||
self.assertIn("child-y", out)
|
||||
|
||||
def test_a_child_pointing_at_its_container_alone_is_not_the_graph(self):
|
||||
"""The shape format.md used to document: the child depends on the
|
||||
container and the container's depends: is empty. It no longer matches
|
||||
what the container's own `## Issues` says, so the check complains."""
|
||||
self.new("feature", "feat-x", "Container x")
|
||||
self.new("task", "child-y", "Child y", depends=["feat-x"])
|
||||
self.fill_issues_section("feat-x", "child-y")
|
||||
_, out = run(issue_check, ["--out", self.root])
|
||||
self.assertIn("warn feat-x:", out)
|
||||
|
||||
def test_issues_section_is_an_edge_source_pointing_down(self):
|
||||
body = "## Issues\n- [ ] child-y — часть\n"
|
||||
self.assertEqual(issue.body_dep_refs(body), ["child-y"])
|
||||
|
||||
|
||||
class WarningNamesItsOwnSection(StoreCase):
|
||||
"""The desync warning quotes the section the reference came from, not
|
||||
`## Depends on` unconditionally — a container has no such section."""
|
||||
|
||||
def test_container_warning_says_issues(self):
|
||||
self.new("feature", "feat-x", "Container x")
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.fill_issues_section("feat-x", "child-y") # but not depends:
|
||||
issues = issue.load_all(self.root)
|
||||
err, warn = issue.validate(issues["feat-x"], known_ids=set(issues))
|
||||
self.assertEqual(err, [])
|
||||
self.assertEqual(
|
||||
warn, ["## Issues mentions 'child-y' but `depends:` does not list it"])
|
||||
self.assertNotIn("## Depends on", "\n".join(warn))
|
||||
body = issue.load(self.root, "feat-x").body
|
||||
self.assertNotIn("## Depends on", body,
|
||||
"the warning must not name a section that is not in the file")
|
||||
|
||||
def test_plain_issue_warning_still_says_depends_on(self):
|
||||
self.new("task", "child-y", "Child y", depends=["migrate-schema"])
|
||||
self.edit("child-y", "depends: [migrate-schema]", "depends: []")
|
||||
issues = issue.load_all(self.root)
|
||||
_, warn = issue.validate(issues["child-y"])
|
||||
self.assertEqual(
|
||||
warn,
|
||||
["## Depends on mentions 'migrate-schema' but `depends:` does not list it"])
|
||||
|
||||
def test_each_reference_is_named_with_its_own_section(self):
|
||||
body = ("## Depends on\n- migrate-schema\n\n"
|
||||
"## Issues\n- [ ] child-y — часть\n")
|
||||
self.assertEqual(
|
||||
issue.body_dep_ref_sections(body),
|
||||
[("## Depends on", "migrate-schema"), ("## Issues", "child-y")])
|
||||
|
||||
def test_body_dep_refs_still_returns_bare_strings(self):
|
||||
"""skills/sync/scripts/map.py filters this list for `#N` refs."""
|
||||
body = "## Depends on\n- migrate-schema\n- #42\n"
|
||||
refs = issue.body_dep_refs(body)
|
||||
self.assertEqual(refs, ["migrate-schema", "#42"])
|
||||
self.assertTrue(all(isinstance(r, str) for r in refs))
|
||||
|
||||
def test_tracker_numbers_never_warn(self):
|
||||
"""`#42` is a tracker handle, not a slug; `depends:` holds ids only."""
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.edit("child-y", "## Motivation", "## Depends on\n- #42\n\n## Motivation")
|
||||
issues = issue.load_all(self.root)
|
||||
_, warn = issue.validate(issues["child-y"])
|
||||
self.assertEqual(warn, [])
|
||||
|
||||
|
||||
class TreePutsTheContainerOnTop(StoreCase):
|
||||
|
||||
def test_container_is_the_root_and_children_hang_below(self):
|
||||
self.container_and_child()
|
||||
code, out = run(issue_tree, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
rows = drawn(out)
|
||||
self.assertEqual(len(rows), 2, out)
|
||||
self.assertTrue(rows[0].startswith("feat-x "), out)
|
||||
self.assertTrue(rows[1].startswith("└── child-y "), out)
|
||||
self.assertEqual(out.count("child-y ["), 1, "child drawn more than once")
|
||||
|
||||
def test_the_container_is_the_only_root(self):
|
||||
self.container_and_child()
|
||||
_, out = run(issue_tree, ["--out", self.root])
|
||||
self.assertIn("# Dependency tree — feat-x", out)
|
||||
|
||||
def test_two_children_hang_off_one_container(self):
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.new("task", "child-z", "Child z")
|
||||
self.new("feature", "feat-x", "Container x",
|
||||
depends=["child-y", "child-z"])
|
||||
self.fill_issues_section("feat-x", "child-y", "child-z")
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
_, tree = run(issue_tree, ["--out", self.root])
|
||||
self.assertEqual(tree.count("feat-x ["), 1,
|
||||
"the container must not repeat once per child")
|
||||
self.assertEqual([r.split(" ")[0] for r in drawn(tree)],
|
||||
["feat-x", "├──", "└──"], tree)
|
||||
self.assertIn("├── child-y ", tree)
|
||||
self.assertIn("└── child-z ", tree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user