merge: reconcile the feature-container convention with the depends validator

This commit is contained in:
naudachu
2026-08-10 15:41:30 +05:00
3 changed files with 299 additions and 17 deletions
+31 -4
View File
@@ -145,6 +145,11 @@ you — `issue_check.py` warns when the section names an id that `depends:` does
not list. Omit the section when there are no dependencies; never write an empty
one.
A `type/feature` container writes the same relation under `## Issues` instead
(see the template below). Same direction, same rule: every id named there also
belongs in that issue's `depends:`. The warning names whichever of the two
sections the reference actually came from.
Draw the graph with `issue_tree.py`. The reverse direction is a grep:
```bash
@@ -258,9 +263,31 @@ grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
## Template: `type/feature`
A container: one unit of business value delivered by several child issues.
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link back
via their `depends:`. Keep implementation detail in the children; the feature
body stays at business level.
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and know
nothing about the container.
**The container depends on its children, never the reverse.** Every child id
goes in the container's own `depends:` and, as prose, in its `## Issues`
section; a child's `depends:` is for that child's real dependencies and must
not point back at the container. Keep implementation detail in the children;
the feature body stays at business level.
That direction is not a convention picked at random. "The container is closed
when its children are closed" *is* a dependency relation. "This child belongs
to that feature" is a membership relation, and membership has no place in a
dependency graph. Pointed the other way the two rules contradict each other:
the moment the container listed a child that already depended on it,
`issue_check.py` would report `ERROR cycle`. With the edge going down, the
graph reads as nesting — `issue_tree.py` draws the container as the root with
its children beneath it — and the check is green.
So the container's metadata block carries the children:
```markdown
depends: [wire-sqlc-appclick, add-pool-cfg]
```
and its body repeats them for a human:
```markdown
## Summary
@@ -274,7 +301,7 @@ body stays at business level.
## Issues
- [ ] wire-sqlc-appclick — краткое описание части
- [ ]
- [ ] add-pool-cfg — краткое описание части
## Acceptance criteria
- [ ] все дочерние issues закрыты
+34 -13
View File
@@ -140,13 +140,20 @@ EXCLUSIVE_NS = ("type/", "severity/")
REQUIRED_SECTIONS = ["## Summary", "## Spec"]
AC_SECTION = "## Acceptance criteria"
DEPENDS_SECTION = "## Depends on"
ISSUES_SECTION = "## Issues"
# Both sections name what an issue depends on, so both are edge sources and
# both point the same way. In a `type/feature` that reads container -> child:
# "the container is closed when its children are closed" IS a dependency.
# "a child belongs to a feature" is membership, and membership has no place in
# a dependency graph — which is why a child never names its container back.
DEP_SECTIONS = (DEPENDS_SECTION, ISSUES_SECTION)
# Per-type sections from the templates — absence is a warning, not a stop.
EXPECTED_SECTIONS = {
"bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"],
"task": ["## Motivation"],
"refactor": ["## Motivation", "## Invariants"],
"test": ["## Motivation", "## Test cases"],
"feature": ["## Motivation", "## Issues"],
"feature": ["## Motivation", ISSUES_SECTION],
"draft": ["## Notes"],
}
@@ -327,24 +334,37 @@ def section_body(body, header):
return "\n".join(out).strip()
def body_dep_refs(body):
"""Tokens referenced from `## Depends on` / `## Issues` only — never from
prose, or a graph walk would drag in half the backlog. Returns whatever was
written there (slugs, and `#N` on issues that came from a tracker)."""
out, active = [], False
def body_dep_ref_sections(body):
"""[(section, ref)] for every reference under one of DEP_SECTIONS — never
from prose, or a graph walk would drag in half the backlog. Refs are
whatever was written there (slugs, and `#N` on issues that came from a
tracker), deduplicated on first sight.
The section is carried out with the ref so a caller can name the one the
reader actually has in front of them: a container's children come from
`## Issues`, and pointing at `## Depends on` would name a section that is
not in the file."""
out, seen, section = [], set(), ""
for line in (body or "").splitlines():
if line.startswith("## "):
active = line.strip() in (DEPENDS_SECTION, "## Issues")
head = line.strip()
section = head if head in DEP_SECTIONS else ""
continue
if not active:
if not section:
continue
for tok in re.findall(r'#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b', line):
ref = ("#" + tok[0]) if tok[0] else tok[1]
if ref not in out:
out.append(ref)
if ref not in seen:
seen.add(ref)
out.append((section, ref))
return out
def body_dep_refs(body):
"""Just the refs, in order of first appearance."""
return [ref for _, ref in body_dep_ref_sections(body)]
# --------------------------------------------------------------------------
# validation
# --------------------------------------------------------------------------
@@ -402,12 +422,13 @@ def validate(issue, known_ids=None):
warn.append("depends on %r, which is not in the store" % d)
# `depends:` is the machine-readable graph; the body section is prose for
# humans. They drift silently unless something says so.
# humans. They drift silently unless something says so. Name the section
# the reference actually came from — for a container that is `## Issues`.
listed = set(issue.depends)
for ref in body_dep_refs(issue.body):
for section, ref in body_dep_ref_sections(issue.body):
if not ref.startswith("#") and ref not in listed:
warn.append("%s mentions %r but `depends:` does not list it"
% (DEPENDS_SECTION, ref))
% (section, ref))
return err, warn