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.
- {prodc-0.2.0 → prodc-0.3.1}/PKG-INFO +1 -1
- {prodc-0.2.0 → prodc-0.3.1}/pyproject.toml +1 -1
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/adapters.py +33 -8
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/cli.py +55 -8
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/status.py +29 -1
- {prodc-0.2.0 → prodc-0.3.1}/tests/test_cli.py +35 -0
- {prodc-0.2.0 → prodc-0.3.1}/.gitignore +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/CHANGELOG.md +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/LICENSE +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/README.md +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/__init__.py +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/config.py +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/loader.py +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/model.py +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/src/prodc/py.typed +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/tests/test_loader.py +0 -0
- {prodc-0.2.0 → prodc-0.3.1}/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.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
|
|
@@ -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
|
|
|
@@ -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':<
|
|
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[:
|
|
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
|
|
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; ``--
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|