prodc 0.2.0__tar.gz → 0.3.0__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.0
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.0"
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
 
@@ -16,7 +16,7 @@ from . import __version__
16
16
  from .config import Config, ConfigError
17
17
  from .loader import LoaderError, load_needs
18
18
  from .model import Need, NeedResult, NeedState, SourceKind
19
- from .status import Summary, resolve, summarize
19
+ from .status import Summary, orphans, resolve, summarize
20
20
 
21
21
  _LABEL = {
22
22
  NeedState.DELIVERED: "DELIVERED",
@@ -99,7 +99,17 @@ def _render(results: list[NeedResult]) -> str:
99
99
  return "\n".join(out)
100
100
 
101
101
 
102
- def _to_json(results: list[NeedResult], summary: Summary) -> str:
102
+ def _render_orphans(orphan_map: dict[str, list[str]]) -> str:
103
+ """The reverse report: proof units that no need references."""
104
+ total = sum(len(v) for v in orphan_map.values())
105
+ out = [f"\norphan proofs (claimed by no need): {total}"]
106
+ for name in sorted(orphan_map):
107
+ for sid in orphan_map[name]:
108
+ out.append(f" {name}:{sid}")
109
+ return "\n".join(out)
110
+
111
+
112
+ def _to_json(results: list[NeedResult], summary: Summary, orphan_map: dict[str, list[str]]) -> str:
103
113
  payload = {
104
114
  "summary": {
105
115
  "total": summary.total,
@@ -110,7 +120,9 @@ def _to_json(results: list[NeedResult], summary: Summary) -> str:
110
120
  "broken": summary.broken,
111
121
  "opinion": summary.opinion,
112
122
  "coverage": round(summary.coverage, 4),
123
+ "orphans": sum(len(v) for v in orphan_map.values()),
113
124
  },
125
+ "orphans": orphan_map,
114
126
  "needs": [
115
127
  {
116
128
  "id": r.need.id,
@@ -147,23 +159,27 @@ def _cmd_status(args: argparse.Namespace) -> int:
147
159
  return 2
148
160
  results = resolve(needs, cfg.adapters)
149
161
  summary = summarize(results)
162
+ orphan_map = orphans(needs, cfg.adapters)
150
163
 
151
164
  if args.json:
152
- print(_to_json(results, summary))
165
+ print(_to_json(results, summary, orphan_map))
153
166
  else:
154
167
  commit = _git_rev(cfg.root)
155
168
  print(f"prodc status — {cfg.root.name}{f' @ {commit}' if commit else ''}")
156
169
  print(_summary_line(summary))
157
170
  print(_render(results))
171
+ if orphan_map:
172
+ print(_render_orphans(orphan_map))
158
173
 
159
- return _exit_code(args, summary)
174
+ return _exit_code(args, summary, sum(len(v) for v in orphan_map.values()))
160
175
 
161
176
 
162
- def _exit_code(args: argparse.Namespace, summary: Summary) -> int:
177
+ def _exit_code(args: argparse.Namespace, summary: Summary, orphan_total: int) -> int:
163
178
  """Gate: 0 unless a threshold is crossed; HOLE never fails.
164
179
 
165
180
  Default fails on any BROKEN; ``--max-dangling N`` tolerates N; ``--min-coverage F``
166
- fails when delivered/total < F; ``--strict`` fails on any opinion.
181
+ fails when delivered/total < F; ``--max-orphans N`` fails above N unclaimed proofs;
182
+ ``--strict`` fails on any opinion.
167
183
  """
168
184
  if args.max_dangling is not None:
169
185
  if summary.broken > args.max_dangling:
@@ -172,6 +188,8 @@ def _exit_code(args: argparse.Namespace, summary: Summary) -> int:
172
188
  return 1
173
189
  if args.min_coverage is not None and summary.coverage < args.min_coverage:
174
190
  return 1
191
+ if args.max_orphans is not None and orphan_total > args.max_orphans:
192
+ return 1
175
193
  if args.strict and summary.opinion:
176
194
  return 1
177
195
  return 0
@@ -214,6 +232,13 @@ def main(argv: list[str] | None = None) -> int:
214
232
  metavar="N",
215
233
  help="gate: tolerate up to N broken locators instead of failing on any",
216
234
  )
235
+ st.add_argument(
236
+ "--max-orphans",
237
+ type=int,
238
+ default=None,
239
+ metavar="N",
240
+ help="gate: exit 1 if more than N proof scenarios are claimed by no need",
241
+ )
217
242
  st.set_defaults(func=_cmd_status)
218
243
 
219
244
  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
 
@@ -91,3 +95,31 @@ def test_strict_fails_on_opinion(tmp_path: Path, capsys: pytest.CaptureFixture[s
91
95
  code = main(["status", "--config", str(_project(tmp_path)), "--strict"])
92
96
  capsys.readouterr()
93
97
  assert code == 1 # O-1 has no source
98
+
99
+
100
+ def test_orphan_report_lists_unclaimed_scenarios(
101
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
102
+ ) -> None:
103
+ code = main(["status", "--config", str(_project(tmp_path))])
104
+ out = capsys.readouterr().out
105
+ assert code == 0 # orphans alone don't fail without a gate
106
+ assert "orphan proofs (claimed by no need): 1" in out
107
+ assert "web:orders.feature#Unclaimed extra" in out
108
+ assert "web:orders.feature#Done" not in out.split("orphan proofs")[1] # Done is claimed
109
+
110
+
111
+ def test_max_orphans_gate(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
112
+ p = str(_project(tmp_path))
113
+ assert main(["status", "--config", p, "--max-orphans", "0"]) == 1 # 1 orphan > 0
114
+ capsys.readouterr()
115
+ assert main(["status", "--config", p, "--max-orphans", "1"]) == 0 # 1 orphan <= 1
116
+ capsys.readouterr()
117
+
118
+
119
+ def test_orphans_in_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
120
+ import json
121
+
122
+ main(["status", "--config", str(_project(tmp_path)), "--json"])
123
+ payload = json.loads(capsys.readouterr().out)
124
+ assert payload["summary"]["orphans"] == 1
125
+ 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