prodc 0.2.0__tar.gz → 0.3.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Product management as code: personas, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
6
6
  Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "Product management as code: personas, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -12,7 +12,7 @@ import re
12
12
  import xml.etree.ElementTree as ET
13
13
  from dataclasses import dataclass
14
14
  from pathlib import Path
15
- from typing import Protocol, cast
15
+ from typing import Protocol, cast, runtime_checkable
16
16
 
17
17
  from .model import ProofState
18
18
 
@@ -43,6 +43,18 @@ class Adapter(Protocol):
43
43
  def resolve(self, payload: str) -> ProofResult: ...
44
44
 
45
45
 
46
+ @runtime_checkable
47
+ class Enumerable(Protocol):
48
+ """An adapter that can list every proof unit it knows and which a payload claims.
49
+
50
+ Needed for the orphan report (proofs referenced by no need). Adapters that can't
51
+ enumerate their universe (e.g. pytest, which resolves by existence) don't implement it.
52
+ """
53
+
54
+ def inventory(self) -> list[str]: ... # canonical ids of every proof unit
55
+ def covers(self, payload: str) -> list[str]: ... # canonical ids a payload claims
56
+
57
+
46
58
  @dataclass(frozen=True)
47
59
  class _Scenario:
48
60
  file: str
@@ -119,16 +131,29 @@ class GherkinAdapter:
119
131
  self._results = out
120
132
  return out
121
133
 
122
- def resolve(self, payload: str) -> ProofResult:
134
+ @staticmethod
135
+ def _id(s: _Scenario) -> str:
136
+ return f"{s.file}#{s.title}"
137
+
138
+ def _match(self, payload: str) -> list[_Scenario]:
139
+ """The scenarios a locator payload refers to (``tag:@X`` or ``file#title``)."""
123
140
  scenarios = self._load()
124
141
  if payload.startswith("tag:"):
125
142
  want = payload[4:]
126
- matches = [s for s in scenarios if want in s.tags]
127
- else:
128
- file_part, _, title = payload.partition("#")
129
- matches = [
130
- s for s in scenarios if s.title == title and (not file_part or s.file == file_part)
131
- ]
143
+ return [s for s in scenarios if want in s.tags]
144
+ file_part, _, title = payload.partition("#")
145
+ return [s for s in scenarios if s.title == title and (not file_part or s.file == file_part)]
146
+
147
+ def inventory(self) -> list[str]:
148
+ """Every scenario, as ``file#title`` — the universe the orphan report diffs against."""
149
+ return sorted({self._id(s) for s in self._load()})
150
+
151
+ def covers(self, payload: str) -> list[str]:
152
+ """The scenarios (as ``file#title``) a referenced payload claims."""
153
+ return sorted({self._id(s) for s in self._match(payload)})
154
+
155
+ def resolve(self, payload: str) -> ProofResult:
156
+ matches = self._match(payload)
132
157
  if not matches:
133
158
  return ProofResult(ProofState.BROKEN, False, f"no scenario matches '{payload}'")
134
159
 
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import shutil
12
13
  import sys
13
14
  from pathlib import Path
14
15
 
@@ -16,7 +17,7 @@ from . import __version__
16
17
  from .config import Config, ConfigError
17
18
  from .loader import LoaderError, load_needs
18
19
  from .model import Need, NeedResult, NeedState, SourceKind
19
- from .status import Summary, resolve, summarize
20
+ from .status import Summary, orphans, resolve, summarize
20
21
 
21
22
  _LABEL = {
22
23
  NeedState.DELIVERED: "DELIVERED",
@@ -53,6 +54,18 @@ def _warrant(need: Need) -> str:
53
54
  return strongest.value
54
55
 
55
56
 
57
+ def _provenance_line(results: list[NeedResult]) -> str:
58
+ """Per-persona provenance mix — makes internal-only ('0 OPINION' but unvalidated) visible.
59
+
60
+ OPINION (no source) stays distinct from a weak INTERNAL source; the headline a PM
61
+ wants is how many needs trace to a real user (interview), not just 'has any source'.
62
+ """
63
+ counts = {"interview": 0, "artifact": 0, "internal": 0, "opinion": 0}
64
+ for r in results:
65
+ counts[_warrant(r.need).split(":", 1)[0]] += 1
66
+ return "provenance: " + " · ".join(f"{counts[k]} {k}" for k in counts)
67
+
68
+
56
69
  def _summary_line(s: Summary) -> str:
57
70
  """The leading gap-count line: what needs attention, and overall coverage."""
58
71
  parts: list[str] = []
@@ -75,10 +88,18 @@ def _render(results: list[NeedResult]) -> str:
75
88
  for r in results:
76
89
  groups.setdefault(r.need.who or "(unassigned)", []).append(r)
77
90
 
91
+ # Size the need column to the terminal; ~54 cols go to the fixed id/state/nm
92
+ # prefix and the warrant suffix. Falls back to 100 cols when there is no tty.
93
+ width = max(30, shutil.get_terminal_size(fallback=(100, 24)).columns - 54)
94
+
78
95
  out: list[str] = []
79
96
  # The Toulmin split: GROUNDS (does a test prove it?) vs WARRANT (user signal it
80
97
  # matters?). A need can be green on one and empty on the other — that gap is the point.
81
- out.append(f" {'':2} {'id':<9} {'GROUNDS':<10}{'':5} {'need':<48} WARRANT")
98
+ out.append(f" {'':2} {'id':<9} {'GROUNDS':<10}{'':5} {'need':<{width}} WARRANT")
99
+ out.append(
100
+ " legend: * declared (no results artifact) · !! must · "
101
+ "GROUNDS = test proof · WARRANT = evidence source"
102
+ )
82
103
  for who in sorted(groups):
83
104
  rs = sorted(groups[who], key=_priority)
84
105
  label = next((x.need.label for x in rs if x.need.label), None)
@@ -86,20 +107,31 @@ def _render(results: list[NeedResult]) -> str:
86
107
  head = " · ".join(f"{counts[s]} {_LABEL[s]}" for s in NeedState if counts[s])
87
108
  title = f"{who}" + (f" ({label})" if label else "")
88
109
  out.append(f"\n{title} {head}")
110
+ out.append(f" {_provenance_line(rs)}")
89
111
  for r in rs:
90
112
  bang = "!!" if r.need.must else " "
91
113
  star = "*" if r.declared and r.state is NeedState.DELIVERED else " "
92
114
  nm = f"{r.delivered}/{r.total}" if r.total else ""
93
115
  out.append(
94
116
  f" {bang} {r.need.id:<9} {_LABEL[r.state]:<9}{star} {nm:<5} "
95
- f"{r.need.text[:48]:<48} {_warrant(r.need)}"
117
+ f"{r.need.text[:width]:<{width}} {_warrant(r.need)}"
96
118
  )
97
119
  for w in r.warnings:
98
120
  out.append(f" ⚠ {w}")
99
121
  return "\n".join(out)
100
122
 
101
123
 
102
- def _to_json(results: list[NeedResult], summary: Summary) -> str:
124
+ def _render_orphans(orphan_map: dict[str, list[str]]) -> str:
125
+ """The reverse report: proof units that no need references."""
126
+ total = sum(len(v) for v in orphan_map.values())
127
+ out = [f"\norphan proofs (claimed by no need): {total}"]
128
+ for name in sorted(orphan_map):
129
+ for sid in orphan_map[name]:
130
+ out.append(f" {name}:{sid}")
131
+ return "\n".join(out)
132
+
133
+
134
+ def _to_json(results: list[NeedResult], summary: Summary, orphan_map: dict[str, list[str]]) -> str:
103
135
  payload = {
104
136
  "summary": {
105
137
  "total": summary.total,
@@ -110,7 +142,9 @@ def _to_json(results: list[NeedResult], summary: Summary) -> str:
110
142
  "broken": summary.broken,
111
143
  "opinion": summary.opinion,
112
144
  "coverage": round(summary.coverage, 4),
145
+ "orphans": sum(len(v) for v in orphan_map.values()),
113
146
  },
147
+ "orphans": orphan_map,
114
148
  "needs": [
115
149
  {
116
150
  "id": r.need.id,
@@ -147,23 +181,27 @@ def _cmd_status(args: argparse.Namespace) -> int:
147
181
  return 2
148
182
  results = resolve(needs, cfg.adapters)
149
183
  summary = summarize(results)
184
+ orphan_map = orphans(needs, cfg.adapters)
150
185
 
151
186
  if args.json:
152
- print(_to_json(results, summary))
187
+ print(_to_json(results, summary, orphan_map))
153
188
  else:
154
189
  commit = _git_rev(cfg.root)
155
190
  print(f"prodc status — {cfg.root.name}{f' @ {commit}' if commit else ''}")
156
191
  print(_summary_line(summary))
157
192
  print(_render(results))
193
+ if orphan_map:
194
+ print(_render_orphans(orphan_map))
158
195
 
159
- return _exit_code(args, summary)
196
+ return _exit_code(args, summary, sum(len(v) for v in orphan_map.values()))
160
197
 
161
198
 
162
- def _exit_code(args: argparse.Namespace, summary: Summary) -> int:
199
+ def _exit_code(args: argparse.Namespace, summary: Summary, orphan_total: int) -> int:
163
200
  """Gate: 0 unless a threshold is crossed; HOLE never fails.
164
201
 
165
202
  Default fails on any BROKEN; ``--max-dangling N`` tolerates N; ``--min-coverage F``
166
- fails when delivered/total < F; ``--strict`` fails on any opinion.
203
+ fails when delivered/total < F; ``--max-orphans N`` fails above N unclaimed proofs;
204
+ ``--strict`` fails on any opinion.
167
205
  """
168
206
  if args.max_dangling is not None:
169
207
  if summary.broken > args.max_dangling:
@@ -172,6 +210,8 @@ def _exit_code(args: argparse.Namespace, summary: Summary) -> int:
172
210
  return 1
173
211
  if args.min_coverage is not None and summary.coverage < args.min_coverage:
174
212
  return 1
213
+ if args.max_orphans is not None and orphan_total > args.max_orphans:
214
+ return 1
175
215
  if args.strict and summary.opinion:
176
216
  return 1
177
217
  return 0
@@ -214,6 +254,13 @@ def main(argv: list[str] | None = None) -> int:
214
254
  metavar="N",
215
255
  help="gate: tolerate up to N broken locators instead of failing on any",
216
256
  )
257
+ st.add_argument(
258
+ "--max-orphans",
259
+ type=int,
260
+ default=None,
261
+ metavar="N",
262
+ help="gate: exit 1 if more than N proof scenarios are claimed by no need",
263
+ )
217
264
  st.set_defaults(func=_cmd_status)
218
265
 
219
266
  args = parser.parse_args(argv)
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  from dataclasses import dataclass
10
10
 
11
- from .adapters import Adapter, ProofResult
11
+ from .adapters import Adapter, Enumerable, ProofResult
12
12
  from .model import (
13
13
  SOLUTION_VERBS,
14
14
  Need,
@@ -140,3 +140,31 @@ def summarize(results: list[NeedResult]) -> Summary:
140
140
  broken=by_state[NeedState.BROKEN],
141
141
  opinion=sum(1 for r in results if r.need.is_opinion),
142
142
  )
143
+
144
+
145
+ def orphans(needs: list[Need], adapters: dict[str, Adapter]) -> dict[str, list[str]]:
146
+ """Proof units claimed by no need, per enumerable adapter — the reverse of the board.
147
+
148
+ The board walks need → proof; this walks proof → need, surfacing scenarios that
149
+ exist in the suite but no need references ("does every feature tie to a need?").
150
+ Only adapters that can enumerate their universe (:class:`~prodc.adapters.Enumerable`,
151
+ e.g. gherkin) participate.
152
+ """
153
+ referenced: dict[str, set[str]] = {}
154
+ for need in needs:
155
+ for locator in need.proofs:
156
+ name, sep, payload = locator.partition(":")
157
+ if sep:
158
+ referenced.setdefault(name, set()).add(payload)
159
+
160
+ out: dict[str, list[str]] = {}
161
+ for name, adapter in adapters.items():
162
+ if not isinstance(adapter, Enumerable):
163
+ continue
164
+ claimed: set[str] = set()
165
+ for payload in referenced.get(name, set()):
166
+ claimed.update(adapter.covers(payload))
167
+ unclaimed = sorted(set(adapter.inventory()) - claimed)
168
+ if unclaimed:
169
+ out[name] = unclaimed
170
+ return out
@@ -46,6 +46,10 @@ Feature: orders
46
46
  Scenario: Done
47
47
  Given a thing
48
48
  Then it works
49
+
50
+ Scenario: Unclaimed extra
51
+ Given another thing
52
+ Then no need references me
49
53
  """
50
54
 
51
55
 
@@ -66,6 +70,9 @@ def test_board_shows_summary_and_warrant(tmp_path: Path, capsys: pytest.CaptureF
66
70
  assert "WARRANT" in out # the warrant column header (#3)
67
71
  assert "interview:John" in out # strongest warrant for D-1
68
72
  assert "opinion" in out # O-1's warrant
73
+ assert "legend:" in out # glyph legend (#10)
74
+ # provenance mix (#11): 1 interview, 1 artifact, 0 internal, 1 opinion
75
+ assert "provenance:" in out and "1 interview" in out and "1 artifact" in out
69
76
 
70
77
 
71
78
  def test_json_has_summary_and_grounds_warrant(
@@ -91,3 +98,31 @@ def test_strict_fails_on_opinion(tmp_path: Path, capsys: pytest.CaptureFixture[s
91
98
  code = main(["status", "--config", str(_project(tmp_path)), "--strict"])
92
99
  capsys.readouterr()
93
100
  assert code == 1 # O-1 has no source
101
+
102
+
103
+ def test_orphan_report_lists_unclaimed_scenarios(
104
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
105
+ ) -> None:
106
+ code = main(["status", "--config", str(_project(tmp_path))])
107
+ out = capsys.readouterr().out
108
+ assert code == 0 # orphans alone don't fail without a gate
109
+ assert "orphan proofs (claimed by no need): 1" in out
110
+ assert "web:orders.feature#Unclaimed extra" in out
111
+ assert "web:orders.feature#Done" not in out.split("orphan proofs")[1] # Done is claimed
112
+
113
+
114
+ def test_max_orphans_gate(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
115
+ p = str(_project(tmp_path))
116
+ assert main(["status", "--config", p, "--max-orphans", "0"]) == 1 # 1 orphan > 0
117
+ capsys.readouterr()
118
+ assert main(["status", "--config", p, "--max-orphans", "1"]) == 0 # 1 orphan <= 1
119
+ capsys.readouterr()
120
+
121
+
122
+ def test_orphans_in_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
123
+ import json
124
+
125
+ main(["status", "--config", str(_project(tmp_path)), "--json"])
126
+ payload = json.loads(capsys.readouterr().out)
127
+ assert payload["summary"]["orphans"] == 1
128
+ assert payload["orphans"]["web"] == ["orders.feature#Unclaimed extra"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes