wdi-method 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -292,7 +292,11 @@ class Corpus:
292
292
 
293
293
  @property
294
294
  def spec_list(self) -> list[dict]:
295
- return rows(self.specs, "specs")
295
+ """A repo migrated before the `waves.yaml` -> `specs.yaml` rename kept its rows' own
296
+ top-level key, `waves:` — the migration renamed the FILE, byte for byte, and never touched
297
+ what is inside it (`registry-migration.test.mjs` pins that as the contract). Reading only
298
+ `specs:` here would make every spec in such a repo invisible, not merely its tickets."""
299
+ return rows(self.specs, "specs") or rows(self.specs, "waves")
296
300
 
297
301
  @property
298
302
  def defect_list(self) -> list[dict]:
@@ -304,13 +308,54 @@ class Corpus:
304
308
  FLAT. The `epics` level between a spec and its tickets is repealed: it grouped rows and
305
309
  bought nothing, and every reader here had to walk through it to reach the row it wanted.
306
310
  A ticket names its `component` directly.
311
+
312
+ A spec still carrying the pre-rename `epics: -> stories:` nesting — a wave closed before
313
+ this repeal — is read here too, via `_legacy_tickets`. Nothing is rewritten in the file:
314
+ the `W<N>` id stays a retired alias, and flattening happens once, in memory, on every run.
307
315
  """
308
316
  out = []
309
317
  for spec in self.spec_list:
310
- for ticket in sorted(spec.get("tickets") or [], key=lambda t: str(t.get("id", ""))):
311
- if isinstance(ticket, dict):
312
- out.append((spec, ticket))
313
- return out
318
+ raw = spec.get("tickets")
319
+ if raw:
320
+ for ticket in raw:
321
+ if isinstance(ticket, dict):
322
+ out.append((spec, ticket))
323
+ else:
324
+ out += [(spec, t) for t in _legacy_tickets(spec)]
325
+ return sorted(out, key=lambda pair: str(pair[1].get("id", "")))
326
+
327
+
328
+ def _legacy_tickets(spec: dict) -> list[dict]:
329
+ """Flatten a pre-rename spec's `epics: -> stories:` into ticket-shaped dicts.
330
+
331
+ A story's own id (`"1"`, `"1-1"`) only ever promised uniqueness inside one epic — the new
332
+ convention's `<spec-id>-<NN>` is global, and this method's own graphs (`no-cycles`,
333
+ `refs-resolve`) key tickets by id across every spec at once. Two waves both naming a story
334
+ `"1"` would otherwise collide into one node the moment both were read here. So every
335
+ synthesized id, and every `depends_on` reference to a sibling, is prefixed with the spec's
336
+ own id — `W3-1`, never bare `1`.
337
+
338
+ The old story key is `depends_on`; the new ticket key is `blocked_by`. No `component` field
339
+ existed on a story, so it is left unset here — a synthesized ticket does not count toward
340
+ `uc-scheduled`'s per-component `touched` set, which is correct: that check is about work not
341
+ yet scheduled, and a closed wave has nothing left to schedule.
342
+ """
343
+ sid = str(spec.get("id") or "")
344
+ out = []
345
+ for epic in spec.get("epics") or []:
346
+ if not isinstance(epic, dict):
347
+ continue
348
+ for story in epic.get("stories") or []:
349
+ if not isinstance(story, dict):
350
+ continue
351
+ ticket = {k: v for k, v in story.items() if k not in ("id", "depends_on")}
352
+ ticket["id"] = f"{sid}-{story.get('id')}"
353
+ ticket["blocked_by"] = [f"{sid}-{d}" for d in (story.get("depends_on") or [])]
354
+ # The story's OWN id is kept because the file on disk is named after it, not after the
355
+ # synthesized ticket id — see `_ticket_files`.
356
+ ticket["_legacy_story_id"] = str(story.get("id") or "")
357
+ out.append(ticket)
358
+ return out
314
359
 
315
360
 
316
361
  def listy(row: dict, key: str) -> list[str]:
@@ -710,8 +755,13 @@ def review_trace(c: Corpus, r: Result) -> None: # was V13
710
755
  + ", ".join(sorted(stale_advisory)))
711
756
 
712
757
 
713
- def cap_tickets(c: Corpus) -> dict[str, list[dict]]:
714
- """CAP -> ticket, traced through CAP -> FR -> UC -> ticket. No git, no timeline."""
758
+ def cap_tickets(c: Corpus) -> dict[str, list[tuple[dict, dict]]]:
759
+ """CAP -> (spec, ticket), traced through CAP -> FR -> UC -> ticket. No git, no timeline.
760
+
761
+ The SPEC travels with its ticket because a ticket's status cannot be read without it — a closed
762
+ spec answers for its own tickets (`_ticket_status`). Dropping it here is what made `plan-dates`
763
+ raise `TypeError` instead of reporting, and a raise there takes the whole run with it.
764
+ """
715
765
  frs_of: dict[str, list[str]] = {}
716
766
  for fr in c.frs:
717
767
  frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
@@ -719,11 +769,11 @@ def cap_tickets(c: Corpus) -> dict[str, list[dict]]:
719
769
  for uc in c.ucs:
720
770
  for fid in listy(uc, "satisfies"):
721
771
  ucs_of.setdefault(fid, []).append(str(uc.get("id")))
722
- out: dict[str, list[dict]] = {}
772
+ out: dict[str, list[tuple[dict, dict]]] = {}
723
773
  for cap in c.caps:
724
774
  cid = str(cap.get("id"))
725
775
  wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
726
- out[cid] = [t for _, t in c.tickets()
776
+ out[cid] = [(spec, t) for spec, t in c.tickets()
727
777
  if wanted & set(listy(t, "satisfies"))]
728
778
  return out
729
779
 
@@ -749,7 +799,7 @@ def plan_dates(c: Corpus, r: Result, asof: dt.date) -> None: # was V14
749
799
  r.fail("plan-dates", cid, f"`planned_end` `{end}` is not an ISO date")
750
800
  continue
751
801
  items = by_cap.get(cid, [])
752
- closed = bool(items) and all(_ticket_status(c, t) == "done" for t in items)
802
+ closed = bool(items) and all(_ticket_status(c, spec, t) == "done" for spec, t in items)
753
803
  if closed or due >= asof:
754
804
  continue
755
805
  late = (asof - due).days
@@ -808,19 +858,28 @@ def ticket_status_one_home(c: Corpus, r: Result) -> None: # was V18
808
858
  shape belongs to the engine that writes them — one file per ticket, numbered from `01` in
809
859
  dependency order — and that number is the tail of the ticket id, which is why `SPEC-3-01`
810
860
  finds `issues/01-*.md`.
861
+
862
+ A CLOSED spec is exempt from the file being present: Phase 4 distillation is what closed it,
863
+ and distillation is what may have removed the file — "dies with it" is not a defect to report
864
+ back at G5. `status` copied into `specs.yaml` is still checked on every ticket regardless, and a
865
+ file that IS present but states no status is still a finding — both are about the record lying,
866
+ not about whether the record still exists.
811
867
  """
812
868
  for spec, ticket in c.tickets():
813
869
  sid = str(ticket.get("id"))
814
870
  if str(ticket.get("status") or "").strip():
815
871
  r.fail("ticket-status-one-home", sid, "carries a `status` in specs.yaml — status lives in the ticket "
816
872
  "file, and two homes for one fact is how a registry starts lying")
873
+ closed = str(spec.get("status") or "").strip() == "closed"
817
874
  folder = _spec_folder(spec, ticket)
818
875
  if not folder:
819
- r.fail("ticket-status-one-home", sid, "its spec does not name a `spec_folder`")
876
+ if not closed:
877
+ r.fail("ticket-status-one-home", sid, "its spec does not name a `spec_folder`")
820
878
  continue
821
879
  matches = _ticket_files(c, spec, ticket)
822
880
  if not matches:
823
- r.fail("ticket-status-one-home", sid, f"has no ticket file under {folder}issues/")
881
+ if not closed:
882
+ r.fail("ticket-status-one-home", sid, f"has no ticket file under {folder}issues/")
824
883
  continue
825
884
  if _read_status(matches[0]) == "unknown":
826
885
  r.fail("ticket-status-one-home", sid, "ticket file states no status — neither a `**Status:**` line nor "
@@ -1547,16 +1606,24 @@ def _ticket_files(c: Corpus, spec: dict, ticket: dict) -> list[Path]:
1547
1606
 
1548
1607
  The full id is tried too, so a product that names its files after the whole id is not punished
1549
1608
  for a convention this method never demanded of it.
1609
+
1610
+ `issues/` arrived WITH the flat `tickets:` shape. A pre-rename wave's files are in
1611
+ `{spec_folder}/stories/`, named by the story's own id (`1-2-<slug>.md`) — so a synthesized
1612
+ legacy ticket is looked up by that id, in both folders, BEFORE the tail-of-the-id fallback:
1613
+ the tail of `W1-1-2` is `2`, which would find nothing here and `1-*.md` for every story in the
1614
+ wave elsewhere. Reporting those as missing reports the migration, not a defect.
1550
1615
  """
1551
1616
  folder = _spec_folder(spec, ticket)
1552
1617
  if not folder:
1553
1618
  return []
1554
1619
  tid = str(ticket.get("id") or "")
1555
- issues = c.root / folder / "issues"
1556
- for stem in (tid.rsplit("-", 1)[-1], tid):
1620
+ story = str(ticket.get("_legacy_story_id") or "")
1621
+ tries = [("stories", story), ("issues", story)] if story else []
1622
+ tries += [("issues", tid.rsplit("-", 1)[-1]), ("issues", tid)]
1623
+ for sub_dir, stem in tries:
1557
1624
  if not stem:
1558
1625
  continue
1559
- found = sorted(issues.glob(f"{stem}-*.md"))
1626
+ found = sorted((c.root / folder / sub_dir).glob(f"{stem}-*.md"))
1560
1627
  if found:
1561
1628
  return found
1562
1629
  return []
@@ -1593,6 +1660,12 @@ def _read_status(path: Path) -> str:
1593
1660
 
1594
1661
 
1595
1662
  def _ticket_status(c: Corpus, spec: dict, ticket: dict) -> str:
1663
+ """A closed spec's tickets are done — Phase 4 gates closure on RTM already being green, and its
1664
+ ticket files may legitimately be gone by then (distillation says they die with the spec). Asking
1665
+ the filesystem what closure already answered is how a correct history goes red on file cleanup.
1666
+ """
1667
+ if str(spec.get("status") or "").strip() == "closed":
1668
+ return "done"
1596
1669
  matches = _ticket_files(c, spec, ticket)
1597
1670
  return _read_status(matches[0]) if matches else "unknown"
1598
1671
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdi-method",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "WDI Method — software delivery method that wraps BMad",
5
5
  "type": "module",
6
6
  "bin": {