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:
naudachu
2026-08-10 15:40:28 +05:00
parent d8bd927f1d
commit fb862554ed
3 changed files with 299 additions and 17 deletions
+234
View File
@@ -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()