ran through analysis with codex this time

This commit is contained in:
pbean 2026-04-30 19:21:26 -07:00
parent 1136a59e0c
commit e80d00d66e
12 changed files with 263 additions and 41 deletions

View File

@ -51,7 +51,7 @@ Execute each entry in `{workflow.activation_steps_prepend}` in order before proc
### Step 3: Load Persistent Facts
Treat every entry in `{workflow.persistent_facts}` as foundational context for the whole run. Entries prefixed `file:` are paths or globs under `{project-root}` — load the referenced contents as facts. All other entries are facts verbatim.
Treat every entry in `{workflow.persistent_facts}` as foundational context for the whole run. Entries prefixed `file:` are paths or globs and may use `{project-root}` (project-local files) or `{skill-root}` (skill-shipped resources); resolve the placeholder, then load the referenced contents as facts. Glob patterns are honored. All other entries are facts verbatim.
### Step 4: Load Config
@ -94,7 +94,7 @@ If the user passed `--from-spec <path>`, set `{mode}=from-spec` and `{spec_path}
- If `{initiative_store}/epics/` contains v7 epic folders (any folder matching `NN-*` with an `epic.md` inside) → `{mode}=edit`.
- If `{initiative_store}/epics/` is absent BUT a v6 monolithic file exists at `{initiative_store}/epics.md` or `{planning_artifacts}/epics.md`, OR a sharded v6 directory exists at the same locations → `{mode}=migrate`.
If both v7 folders and a v6 file exist, prefer `edit` and surface the v6 file in Stage 1 as a one-line note.
If both v7 folders and a v6 file exist, prefer `edit`. The edit-mode prompt surfaces the leftover v6 file as a one-line note before any sub-mode dispatches.
### 4. Edit sub-mode dispatch (only when `{mode}=edit`)

View File

@ -28,8 +28,12 @@ activation_steps_append = []
#
# Each entry is either:
# - a literal sentence, e.g. "Stories must reference at least one FR or UX-DR."
# - a file reference prefixed with `file:`, e.g. "file:{project-root}/docs/standards.md"
# (glob patterns are supported; the file's contents are loaded and treated as facts).
# - a file reference prefixed with `file:`. The path may use either
# `{project-root}` (project-local docs) or `{skill-root}` (skill-shipped
# resources). Glob patterns are honored; the file's contents are loaded
# and treated as facts. Examples:
# "file:{project-root}/docs/standards.md"
# "file:{skill-root}/resources/sizing-heuristics.md"
persistent_facts = [
"file:{project-root}/**/project-context.md",

View File

@ -18,6 +18,14 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} --s
The JSON's `summary.epics[]` already contains, per epic: folder, NN, title, status, depends_on, story_count, and per-story metadata (basename, title, type, status, depends_on). Use this — do not read every `epic.md` and every story file.
### Refuse to dispatch when the tree is broken
`--summary-only` suppresses the detailed `findings[]` list, but it still includes `summary.errors` and `summary.warnings` counts. **If `summary.errors > 0`, do not proceed into any sub-mode.** Re-run the validator without `--summary-only` to surface the findings, route to `prompts/validate.md` to fix them, and only return to this prompt once the tree validates clean. Edit-mode flows like `split-epic` and `re-derive-deps` rewrite many files; running them on top of a parse or schema error compounds the damage.
### Surface a stale v6 file (one-line note)
If a v6 monolithic file exists alongside the v7 tree (at `{initiative_store}/epics.md` or `{planning_artifacts}/epics.md`), surface it once before dispatch as a single line: `"there's still a v6 epics.md at <path> — leave it, archive it, or migrate it via a fresh create flow"`. The v7 tree is canonical; the v6 file is treated as inert. Do not stop or branch — continue to the sub-mode the user asked for.
## Ambiguous-intent menu
If the user's opening message did not match any sub-mode, ask:

View File

@ -12,7 +12,7 @@
- **User-value first.** Each epic must enable users to accomplish something meaningful, or — for tech-debt epics — leave a measurably better engineering state. Epics organized by technical layers ("database setup," "API endpoints," "frontend components") are wrong; reshape them.
- **Standalone within the dependency graph.** Each epic delivers complete functionality for its domain. Epic 2 must not require Epic 3 to function. Epic 3 may build on 1 and 2 but must stand alone.
- **Dependency-free within an epic.** Stories within an epic must not depend on later stories in the same epic. (The validator enforces this in Stage 5 via `depends_on` resolution.)
- **Dependency-free within an epic.** Stories within an epic must not depend on later stories in the same epic. (The validator enforces this in Stage 5: `story-dep-forward` rejects within-epic deps that point at later siblings, and `story-dep-cycle` rejects loops in the story-level graph.)
- **File-churn check.** If multiple proposed epics repeatedly modify the same core files, ask whether they should consolidate into one epic with ordered stories. Distinguish meaningful overlap (same component end-to-end) from incidental sharing. Consolidate when the split provides no risk-mitigation or feedback-loop value.
- **Implementation efficiency over taxonomy.** When the outcome is certain and direction changes between epics are unlikely, prefer fewer larger epics. Split into more epics when there's a genuine risk boundary or where early feedback could change direction.
- **Starter template (if Stage 2 flagged one in the inventory).** Epic 1's first story must be "set up the project from the starter template." Plan for it now.

View File

@ -38,11 +38,11 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} --s
From the JSON, surface a 3-line block such as:
```
2 epics, 5 stories: 3 features, 2 tasks. Coverage: 100% (8/8 inventory codes mentioned).
2 epics, 5 stories: 3 features, 2 tasks. Coverage: 100% (8/8 inventory codes claimed by some story's ## Coverage section).
Median story body: ~600 chars. No oversized stories.
```
Pull the coverage number from `summary.mentioned_requirements` vs the inventory at `{initiative_store}/.bmad-cache/inventory.json` (if present); skip the coverage line when no inventory exists.
Pull the coverage number from `summary.mentioned_requirements` (the strict Coverage-section set) vs the inventory at `{initiative_store}/.bmad-cache/inventory.json` (if present); skip the coverage line when no inventory exists.
## Step 3: Confirm initial statuses

View File

@ -15,14 +15,14 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} \
--inventory {initiative_store}/.bmad-cache/inventory.json
```
Without `--inventory`, the validator only checks schema/deps/cycles/numbering and emits the regex-extracted `mentioned_requirements` set; coverage findings will not be generated.
Without `--inventory`, the validator only checks schema/deps/cycles/numbering and emits the Coverage-section codes as `mentioned_requirements`; coverage findings will not be generated.
The JSON contract:
- `findings[]` — every error and warning. New code: `coverage-missing` for inventory codes that don't appear textually in any story body. Default level is `warning`; pass `--coverage-strict` to escalate to `error`.
- `findings[]` — every error and warning. `coverage-missing` fires for inventory codes that no story's `## Coverage` section claims (mentions in prose elsewhere do not count). Default level is `warning`; pass `--coverage-strict` to escalate to `error`. Other dependency-shape codes: `story-dep-forward` (within-epic dep on a later sibling), `story-dep-cycle` (loop in the story-level depends_on graph), `epic-dep-cycle` (loop in the epic-level graph).
- `summary.epics[]` — full per-epic summary including story-level metadata (basename, title, type, status, depends_on, body_len). Use this instead of re-reading every file.
- `summary.mentioned_requirements` — deduplicated set of codes the regex found.
- `summary.coverage_missing` — codes from the inventory not found in any story body (only populated when `--inventory` was passed).
- `summary.mentioned_requirements` — deduplicated set of codes the validator found in any story's `## Coverage` section. The strict source for the coverage gate.
- `summary.coverage_missing` — codes from the inventory not claimed by any story's `## Coverage` section (only populated when `--inventory` was passed).
## Headless mode
@ -43,13 +43,16 @@ For each error in `findings`, explain it in one sentence and offer to fix. Group
- **`epic-nn-mismatch` / `story-epic-mismatch`** → likely a hand-edit of the front matter; the folder name is canonical, so update the front matter to match.
- **`story-dep-unresolved`** → either the dep was a typo (fix the depends_on entry) or the target was renamed (`scripts/rename_story.py`) or moved (`scripts/move_story.py`) without updating refs. Use the move/rename scripts going forward — they update refs atomically.
- **`epic-dep-cycle`** → cross-epic graph has a loop. Loop back to `prompts/epic-design.md` (re-derive-deps flow) to fix it.
- **`story-numbering-gaps`** → use `scripts/rename_story.py --to-nn` to fill the gap or renumber the survivors.
- **`story-dep-forward`** → a within-epic dep points at a later sibling. Either reorder the stories with `rename_story.py --to-nn` so the dep points backward, or move the dep target into an upstream epic and use the cross-epic ref form.
- **`story-dep-cycle`** → loop in the story-level graph. Loop back to `prompts/edit-mode.md` re-derive-deps. Often a sign two stories should be merged.
- **`story-numbering-gaps`** / **`story-numbering-duplicates`** → use `scripts/rename_story.py --to-nn` to fill the gap or break the duplicate.
- **`epic-filter-not-found`** → the `--epic` flag named a folder that doesn't exist. Spell-check the folder name (including the `NN-` prefix), or drop the flag.
### 2. Coverage check
When `--inventory` was passed, the validator already produced `coverage_missing` deterministically — surface those codes conversationally and route into the **coverage-fix** edit-mode entry point in `prompts/epic-authoring.md`.
For each missing code: ask whether it should be added to an existing story's AC mapping or whether a new story is needed. The validator does not distinguish between "code not mentioned anywhere" and "code mentioned in prose without the literal token" — when you suspect the latter, fan out `agents/coverage-auditor.md` to do a fuzzy semantic check.
For each missing code: ask whether it should be added to an existing story's AC mapping or whether a new story is needed. A code mentioned in Technical Notes (or any prose outside the `## Coverage` section) is treated as uncovered — that's by design, since the AC→codes contract is what downstream skills like `bmad-code-review` rely on. If you suspect the requirement *is* implicitly covered but no story claims its code, fan out `agents/coverage-auditor.md` to do a fuzzy semantic check before adding new stories.
#### Speeding up the auditor with a deterministic pre-pass

View File

@ -40,6 +40,8 @@ These fire when references don't resolve.
| `epic-dep-unresolved` | An epic's `depends_on` references an NN that has no corresponding folder in the tree. | Either fix the typo, or remove the dep, or create the missing epic. |
| `epic-dep-cycle` | The cross-epic depends_on graph has a cycle (Epic A → B → A, directly or transitively). The message lists the cycle. | Break the cycle. If the cycle reflects a real bidirectional dependency, the epics should probably be merged or the seam reconsidered. |
| `story-dep-unresolved` | A story's `depends_on` entry doesn't resolve — either as a within-epic basename or as a cross-epic `<folder>/<basename>` ref. | Fix the typo, or use `move_story.py` / `rename_story.py` going forward (they update refs atomically). For after-the-fact fixes, edit `depends_on:` directly and re-validate. |
| `story-dep-forward` | A story's within-epic `depends_on` references a sibling whose NN is greater than or equal to its own. Within an epic, stories must build on earlier ones. | Reorder (re-NN) the stories with `rename_story.py --to-nn` so the dependency points backward, or move the dep target to an upstream epic and use the cross-epic ref form. |
| `story-dep-cycle` | The story-level `depends_on` graph (across the whole tree) has a cycle. The message lists the cycle in `<epic>/<basename>` form. | Break the cycle. Often a symptom of stories that should be merged, or of a missed seam where one of them should split off into an earlier epic. |
## Structural errors
@ -48,12 +50,14 @@ These fire when references don't resolve.
| `no-epics-dir` | `{initiative_store}/epics/` doesn't exist. | Either you have the wrong `--initiative-store`, or no tree has been generated yet. Run the skill in create mode. |
| `missing-epic-md` | A folder matches `NN-*` but has no `epic.md` inside. | Either the folder is bogus (delete it) or the `epic.md` was lost (regenerate with `init_epic.py` and re-fill). |
| `story-numbering-gaps` | Story NNs in an epic are not sequential `01..N`. The message lists what was found vs expected. | Use `rename_story.py --to-nn` to fill the gap or renumber the survivors. |
| `story-numbering-duplicates` | Two or more stories in the same epic share an NN. The message lists the duplicated NNs. | Renumber one of them with `rename_story.py --to-nn`. Don't try to renumber the survivors with `move_story.py` — moving across epics is a different operation. |
| `epic-filter-not-found` | `--epic <name>` was passed but no folder under `{initiative_store}/epics/` matches. | Spell-check the folder name (including the `NN-` prefix), or drop the flag to scan the whole tree. |
## Coverage findings
| Code | Meaning | Fix |
| --- | --- | --- |
| `coverage-missing` | An inventory code from `--inventory <file>` does not appear textually in any story body. **Warning** by default; **error** under `--coverage-strict`. | Use the `coverage-fix` edit-mode entry point in `prompts/edit-mode.md` — extend an existing story's `## Coverage` section, or add a new story for the missing code. |
| `coverage-missing` | An inventory code from `--inventory <file>` does not appear in any story's `## Coverage` section. Mentions in prose elsewhere in the body (Technical Notes, Goal, etc.) do **not** count — coverage is the AC→codes contract, not free-form description. **Warning** by default; **error** under `--coverage-strict`. | Use the `coverage-fix` edit-mode entry point in `prompts/edit-mode.md` — extend an existing story's `## Coverage` section, or add a new story for the missing code. |
## Sizing warnings

View File

@ -49,13 +49,24 @@ AC_LINE_RE = re.compile(r"\bAC\d+\b", re.IGNORECASE)
def extract_coverage_section(text: str) -> str | None:
m = COVERAGE_HEADING_RE.search(text)
if not m:
"""Return the concatenated body of every ``## Coverage`` block in ``text``.
Both authoring flows are tolerated: replacing the template's placeholder
Coverage block in-place (the intended flow), or appending a new
``## Coverage`` block at the end of the file (a common LLM/human mistake
that we don't want to silently flip into "uncovered").
Returns ``None`` only when no ``## Coverage`` heading exists at all.
"""
matches = list(COVERAGE_HEADING_RE.finditer(text))
if not matches:
return None
parts: list[str] = []
for m in matches:
start = m.end()
nxt = NEXT_HEADING_RE.search(text, start)
end = nxt.start() if nxt else len(text)
return text[start:end]
parts.append(text[start:end])
return "\n".join(parts)
def parse_coverage_section(section: str) -> dict[str, list[str]]:

View File

@ -4,7 +4,7 @@
# ///
"""Generate a complete v7 epic-and-story tree from a structured spec, then validate.
Spec schema (JSON or YAML-equivalent JSON):
Spec schema (JSON):
{
"title": "...", # initiative name (informational)
"intent": "...", # one-line intent (informational)
@ -75,7 +75,7 @@ def _load_spec(path: Path) -> dict:
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise SystemExit(f"could not parse spec as JSON: {exc}")
raise SystemExit(f"could not parse spec as JSON: {exc}") from exc
def _run(cmd: list[str]) -> tuple[int, str, str]:
@ -148,6 +148,26 @@ def _validate_spec(spec: dict) -> list[str]:
errs.append(f"epic[{i}].stories[{j}] missing `{k}`")
if story.get("type") not in {"feature", "task", "bug", "spike"}:
errs.append(f"epic[{i}].stories[{j}].type invalid: {story.get('type')!r}")
inv = spec.get("inventory")
if inv is not None:
if not isinstance(inv, dict):
errs.append("inventory must be an object")
else:
reqs = inv.get("requirements")
if reqs is not None:
if not isinstance(reqs, dict):
errs.append("inventory.requirements must be an object keyed by category")
else:
for cat, entries in reqs.items():
if not isinstance(entries, list):
errs.append(f"inventory.requirements.{cat} must be a list")
continue
for k_idx, entry in enumerate(entries):
if not isinstance(entry, dict):
errs.append(f"inventory.requirements.{cat}[{k_idx}] must be an object")
elif "code" not in entry:
errs.append(f"inventory.requirements.{cat}[{k_idx}] missing `code`")
return errs

View File

@ -88,6 +88,22 @@ class TestFromSpec(unittest.TestCase):
data = json.loads(r.stdout)
self.assertTrue(any("type invalid" in msg for msg in data["details"]))
def test_invalid_inventory_shape_rejected(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = Path(tmp) / "store"
spec_data = json.loads(json.dumps(BASE_SPEC))
# Drop the `code` from one entry; spec validator should reject before authoring.
spec_data["inventory"]["requirements"]["functional"] = [{"text": "Missing code"}]
spec = Path(tmp) / "spec.json"
spec.write_text(json.dumps(spec_data), encoding="utf-8")
r = _run("--initiative-store", str(store), "--spec", str(spec))
self.assertEqual(r.returncode, 1)
data = json.loads(r.stdout)
self.assertEqual(data["error"], "invalid spec")
self.assertTrue(any("missing `code`" in msg for msg in data["details"]))
# Tree must not have been created on a rejected spec.
self.assertFalse((store / "epics").exists())
def test_coverage_strict_fails_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = Path(tmp) / "store"

View File

@ -181,6 +181,87 @@ class TestValidateInitiative(unittest.TestCase):
self.assertEqual(r.returncode, 1)
self.assertIn("not found", r.stderr)
def test_within_epic_forward_ref_rejected(self) -> None:
# 01-schema must not depend on 02-register; forward-deps within the same
# epic violate the "stories build on earlier siblings" invariant.
with tempfile.TemporaryDirectory() as tmp:
store = Path(tmp)
_build_clean_tree(store)
sp = store / "epics" / "01-auth" / "01-schema.md"
sp.write_text(sp.read_text(encoding="utf-8").replace("depends_on: []", 'depends_on: ["02-register"]', 1), encoding="utf-8")
r = _run(VALIDATE, "--initiative-store", str(store))
self.assertEqual(r.returncode, 1)
codes = {f["code"] for f in json.loads(r.stdout)["findings"]}
self.assertIn("story-dep-forward", codes)
def test_story_dep_cycle_detected(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = Path(tmp)
_build_clean_tree(store)
schema = store / "epics" / "01-auth" / "01-schema.md"
register = store / "epics" / "01-auth" / "02-register.md"
schema.write_text(schema.read_text(encoding="utf-8").replace("depends_on: []", 'depends_on: ["02-register"]', 1), encoding="utf-8")
# 02-register already depends on 01-schema; adding the reverse edge
# closes the loop.
r = _run(VALIDATE, "--initiative-store", str(store))
self.assertEqual(r.returncode, 1)
codes = {f["code"] for f in json.loads(r.stdout)["findings"]}
self.assertIn("story-dep-cycle", codes)
def test_story_numbering_duplicates_distinct_from_gaps(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = Path(tmp)
_build_clean_tree(store)
# Force a duplicate by hand-placing a second NN=01 file in 01-auth.
dup = store / "epics" / "01-auth" / "01-second.md"
dup.write_text(
"---\ntitle: \"Second\"\ntype: task\nstatus: draft\nepic: 01-auth\ndepends_on: []\n---\n\n# Second\n",
encoding="utf-8",
)
r = _run(VALIDATE, "--initiative-store", str(store))
self.assertEqual(r.returncode, 1)
codes = {f["code"] for f in json.loads(r.stdout)["findings"]}
self.assertIn("story-numbering-duplicates", codes)
self.assertNotIn("story-numbering-gaps", codes)
def test_epic_filter_missing_folder_errors(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = Path(tmp)
_build_clean_tree(store)
r = _run(VALIDATE, "--initiative-store", str(store), "--epic", "99-nope")
self.assertEqual(r.returncode, 1)
codes = {f["code"] for f in json.loads(r.stdout)["findings"]}
self.assertIn("epic-filter-not-found", codes)
def test_coverage_gate_only_counts_coverage_section(self) -> None:
# FR99 mentioned only in Technical Notes must NOT count as covered, even
# though the body-wide regex would match it.
with tempfile.TemporaryDirectory() as tmp:
store = Path(tmp)
_build_clean_tree(store)
inv = store / "inventory.json"
inv.write_text(json.dumps({
"requirements": {
"functional": [{"code": "FR1", "text": "Login"}, {"code": "FR99", "text": "Hidden"}],
}
}), encoding="utf-8")
schema = store / "epics" / "01-auth" / "01-schema.md"
text = schema.read_text(encoding="utf-8")
text = text.replace(
"- AC1 → <list of FR / NFR / UX-DR codes>",
"- AC1 → FR1",
)
text = text.replace(
"<Implementation hints — file paths, API contracts, gotchas, references into the epic's Shared Context. Not a full design; just what saves the implementer a lookup.>",
"We rely on FR99 here.",
)
schema.write_text(text, encoding="utf-8")
r = _run(VALIDATE, "--initiative-store", str(store), "--inventory", str(inv), "--coverage-strict")
self.assertEqual(r.returncode, 1, r.stdout)
data = json.loads(r.stdout)
self.assertEqual(data["summary"]["coverage_missing"], ["FR99"])
self.assertNotIn("FR99", data["summary"]["mentioned_requirements"])
def test_lax_skips_sizing_warnings(self) -> None:
# Sizing warnings fire when one body exceeds 3x the epic mean. With 5 normal
# stories and one massively-padded outlier, the mean stays low enough for

View File

@ -10,10 +10,12 @@ Checks (strict mode):
3. Story `epic:` field equals the enclosing folder name; epic `epic:` field equals the folder NN.
4. depends_on entries resolve (within-epic basenames or <epic-folder>/<basename> cross-epic).
5. Cross-epic depends_on graph is acyclic.
6. Within-epic story numbering is sequential starting at 01.
7. Sizing sanity (warnings only): a story body >3x the epic mean is flagged.
8. Coverage (only when --inventory FILE is provided): every requirement code listed
in the inventory must appear textually in at least one story body.
6. Story-level depends_on graph is acyclic; within-epic deps must point at earlier siblings.
7. Within-epic story numbering is sequential starting at 01, with no duplicates.
8. Sizing sanity (warnings only): a story body >3x the epic mean is flagged.
9. Coverage (only when --inventory FILE is provided): every requirement code listed in
the inventory must appear in at least one story's `## Coverage` section. Codes
mentioned elsewhere in the body (Technical Notes etc.) do NOT count as covered.
Output (stdout, JSON): {"findings": [...], "summary": {...}}
--summary-only emits a structured tree block instead, used by edit-mode and finalize.
@ -22,7 +24,8 @@ Exit codes: 0 if no errors (warnings ok), 1 if any error finding, 2 on internal
Flags:
--lax skip sizing warnings; never relaxes schema or dep checks
--epic NN-kebab limit walks to a single epic folder (still resolves cross-epic refs)
--epic NN-kebab limit walks to a single epic folder (still resolves cross-epic refs).
Errors if the named folder doesn't exist.
--inventory FILE path to inventory.json (or .bmad-cache/inventory.json); when present,
missing requirement codes are reported. Default level is warning;
pair with --coverage-strict to escalate to error.
@ -41,6 +44,10 @@ import sys
from collections import Counter, defaultdict
from pathlib import Path
# Same directory; both files are shipped together as the skill's scripts/.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from extract_coverage import extract_coverage_section, parse_coverage_section # noqa: E402
STORY_TYPES = {"feature", "bug", "task", "spike"}
STATUSES = {"draft", "ready", "in-progress", "review", "done", "blocked"}
STORY_KEYS = {"title", "type", "status", "epic", "depends_on", "metadata"}
@ -48,7 +55,13 @@ STORY_REQUIRED = {"title", "type", "status", "epic", "depends_on"}
EPIC_KEYS = {"title", "epic", "status", "depends_on", "metadata"}
EPIC_REQUIRED = {"title", "epic", "status", "depends_on"}
REQUIREMENT_CODE_RE = re.compile(r"\b(?:UX-DR|NFR|FR|D|R)\d+(?:\.\d+)?\b")
def _story_coverage_codes(stext: str) -> set[str]:
"""Codes claimed by a story's ``## Coverage`` section. Empty if no section."""
section = extract_coverage_section(stext)
if section is None:
return set()
ac_map = parse_coverage_section(section)
return {c for codes in ac_map.values() for c in codes}
def parse_frontmatter(text: str) -> tuple[dict | None, str | None]:
@ -211,6 +224,23 @@ def validate(
return findings, {}
all_epic_folders = sorted(p for p in epics_dir.iterdir() if p.is_dir() and re.match(r"^\d+-", p.name))
if only_epic is not None and not any(p.name == only_epic for p in all_epic_folders):
findings.append({
"level": "error",
"code": "epic-filter-not-found",
"message": f"--epic {only_epic!r} does not match any folder under {epics_dir}",
"path": str(epics_dir),
})
return findings, {
"epics": [],
"story_count": 0,
"story_status_counts": {},
"story_type_counts": {},
"errors": 1,
"warnings": 0,
"mentioned_requirements": [],
"coverage_missing": [],
}
walk_folders = [p for p in all_epic_folders if (only_epic is None or p.name == only_epic)]
epic_meta: dict[str, dict] = {}
@ -301,7 +331,7 @@ def validate(
if in_walk:
findings.append({"level": "error", "code": "story-deps-not-list", "message": "depends_on must be a list", "path": str(sf)})
sdeps = []
mentioned_codes.update(REQUIREMENT_CODE_RE.findall(stext))
mentioned_codes.update(_story_coverage_codes(stext))
story_index[f"{ed.name}/{sf.stem}"] = {
"depends_on": [str(d) for d in sdeps],
"path": sf,
@ -316,9 +346,20 @@ def validate(
}
if in_walk and seen_nns:
expected = list(range(1, len(seen_nns) + 1))
if sorted(seen_nns) != expected:
findings.append({"level": "error", "code": "story-numbering-gaps", "message": f"story NNs {sorted(seen_nns)} expected {expected}", "path": str(ed)})
counts = Counter(seen_nns)
duplicates = sorted(n for n, c in counts.items() if c > 1)
if duplicates:
findings.append({
"level": "error",
"code": "story-numbering-duplicates",
"message": f"duplicate story NNs in {ed.name}: {duplicates}",
"path": str(ed),
})
else:
unique_sorted = sorted(set(seen_nns))
expected = list(range(1, len(unique_sorted) + 1))
if unique_sorted != expected:
findings.append({"level": "error", "code": "story-numbering-gaps", "message": f"story NNs {unique_sorted} expected {expected}", "path": str(ed)})
# depends_on resolution
epic_nns = {meta["nn"]: name for name, meta in epic_meta.items()}
@ -330,16 +371,32 @@ def validate(
if d2 not in epic_nns:
findings.append({"level": "error", "code": "epic-dep-unresolved", "message": f"epic {name} depends on NN {d!r} which has no folder", "path": str(meta["path"])})
# Story dep resolution + within-epic forward-ref check.
# Build the story dependency graph on the whole tree so cycle detection
# catches loops that span epics, even when the run is filtered to one.
story_graph: dict[str, list[str]] = {}
for skey, smeta in story_index.items():
if not smeta["in_walk"]:
continue
targets: list[str] = []
for d in smeta["depends_on"]:
target_key = d if "/" in d else f"{smeta['epic']}/{d}"
if target_key not in story_index:
if smeta["in_walk"]:
if "/" in d:
if f"{d.split('/', 1)[0]}/{d.split('/', 1)[1]}" not in story_index:
findings.append({"level": "error", "code": "story-dep-unresolved", "message": f"cross-epic dep {d!r} references missing story", "path": str(smeta["path"])})
else:
if f"{smeta['epic']}/{d}" not in story_index:
findings.append({"level": "error", "code": "story-dep-unresolved", "message": f"within-epic dep {d!r} not found in {smeta['epic']}", "path": str(smeta["path"])})
continue
targets.append(target_key)
if smeta["in_walk"]:
tgt = story_index[target_key]
if tgt["epic"] == smeta["epic"] and tgt["nn"] >= smeta["nn"]:
findings.append({
"level": "error",
"code": "story-dep-forward",
"message": f"depends on later sibling {d!r} (NN {tgt['nn']:02d} ≥ self NN {smeta['nn']:02d}); within-epic deps must point at earlier stories",
"path": str(smeta["path"]),
})
story_graph[skey] = targets
# epic dep cycles (compute on whole tree; report once)
if walk_set:
@ -347,6 +404,24 @@ def validate(
for cyc in _find_cycles(cycle_graph):
findings.append({"level": "error", "code": "epic-dep-cycle", "message": "cycle in epic depends_on: " + " -> ".join(cyc), "path": str(epics_dir)})
seen_cycle_keys: set[frozenset[str]] = set()
for cyc in _find_cycles(story_graph):
# report only once per distinct cycle, and only when at least one
# node lies in the walk (so --epic filtering still hides loops in
# untouched parts of the tree).
if not any(story_index.get(n, {}).get("in_walk") for n in cyc):
continue
key = frozenset(cyc)
if key in seen_cycle_keys:
continue
seen_cycle_keys.add(key)
findings.append({
"level": "error",
"code": "story-dep-cycle",
"message": "cycle in story depends_on: " + " -> ".join(cyc),
"path": str(epics_dir),
})
# sizing warnings
if not lax:
by_epic: dict[str, list] = defaultdict(list)
@ -376,7 +451,7 @@ def validate(
findings.append({
"level": level,
"code": "coverage-missing",
"message": f"requirement {code!r} ({text[:60]}...) not referenced by any story body" if text else f"requirement {code!r} not referenced by any story body",
"message": f"requirement {code!r} ({text[:60]}...) not referenced in any story's ## Coverage section" if text else f"requirement {code!r} not referenced in any story's ## Coverage section",
"path": str(initiative_store / "epics"),
})