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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1556
|
-
|
|
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(
|
|
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
|
|