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.
- {prodc-0.2.0 → prodc-0.3.0}/PKG-INFO +1 -1
- {prodc-0.2.0 → prodc-0.3.0}/pyproject.toml +1 -1
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/adapters.py +33 -8
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/cli.py +31 -6
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/status.py +29 -1
- {prodc-0.2.0 → prodc-0.3.0}/tests/test_cli.py +32 -0
- {prodc-0.2.0 → prodc-0.3.0}/.gitignore +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/CHANGELOG.md +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/LICENSE +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/README.md +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/__init__.py +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/config.py +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/loader.py +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/model.py +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/src/prodc/py.typed +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/tests/test_loader.py +0 -0
- {prodc-0.2.0 → prodc-0.3.0}/tests/test_status.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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; ``--
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|