prodc 0.2.0__py3-none-any.whl
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/__init__.py +3 -0
- prodc/adapters.py +194 -0
- prodc/cli.py +224 -0
- prodc/config.py +75 -0
- prodc/loader.py +141 -0
- prodc/model.py +111 -0
- prodc/py.typed +0 -0
- prodc/status.py +142 -0
- prodc-0.2.0.dist-info/METADATA +71 -0
- prodc-0.2.0.dist-info/RECORD +13 -0
- prodc-0.2.0.dist-info/WHEEL +4 -0
- prodc-0.2.0.dist-info/entry_points.txt +2 -0
- prodc-0.2.0.dist-info/licenses/LICENSE +21 -0
prodc/__init__.py
ADDED
prodc/adapters.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Proof adapters: resolve a locator payload to a :class:`ProofState`.
|
|
2
|
+
|
|
3
|
+
An adapter reads a repo as it already is (Gherkin titles, pytest node ids). Status
|
|
4
|
+
comes from a test-results artifact (cucumber-json / junit) when configured and
|
|
5
|
+
present; otherwise it reports a *declared* state (from tags or existence) and says so.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol, cast
|
|
16
|
+
|
|
17
|
+
from .model import ProofState
|
|
18
|
+
|
|
19
|
+
_TAG_RE = re.compile(r"@[\w\-.]+")
|
|
20
|
+
_SCENARIO_RE = re.compile(r"^\s*(?:Scenario|Scenario Outline):\s*(.*?)\s*$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _steps_passed(steps: list[object]) -> bool:
|
|
24
|
+
"""A scenario passes iff every step's result status is 'passed' (cucumber-json)."""
|
|
25
|
+
for step in steps:
|
|
26
|
+
if not isinstance(step, dict):
|
|
27
|
+
return False
|
|
28
|
+
result = cast("dict[str, object]", step).get("result")
|
|
29
|
+
status = cast("dict[str, object]", result).get("status") if isinstance(result, dict) else None
|
|
30
|
+
if status != "passed":
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ProofResult:
|
|
37
|
+
state: ProofState
|
|
38
|
+
declared: bool # True when the state rests on tags/existence, not a results artifact
|
|
39
|
+
detail: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Adapter(Protocol):
|
|
43
|
+
def resolve(self, payload: str) -> ProofResult: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class _Scenario:
|
|
48
|
+
file: str
|
|
49
|
+
title: str
|
|
50
|
+
tags: frozenset[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GherkinAdapter:
|
|
54
|
+
"""Resolves ``file#Scenario title`` or ``tag:@US-050`` against feature files."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
root: Path,
|
|
59
|
+
paths: list[str],
|
|
60
|
+
delivered_tags: list[str],
|
|
61
|
+
pending_tags: list[str],
|
|
62
|
+
results: str | None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._root = root
|
|
65
|
+
self._paths = paths
|
|
66
|
+
self._delivered = frozenset(delivered_tags)
|
|
67
|
+
self._pending = frozenset(pending_tags)
|
|
68
|
+
self._results_path = results
|
|
69
|
+
self._scenarios: list[_Scenario] | None = None
|
|
70
|
+
self._results: dict[str, bool] | None = None
|
|
71
|
+
|
|
72
|
+
def _load(self) -> list[_Scenario]:
|
|
73
|
+
if self._scenarios is not None:
|
|
74
|
+
return self._scenarios
|
|
75
|
+
found: list[_Scenario] = []
|
|
76
|
+
for pattern in self._paths:
|
|
77
|
+
for fp in sorted(self._root.glob(pattern)):
|
|
78
|
+
pending: set[str] = set()
|
|
79
|
+
for line in fp.read_text(encoding="utf-8").splitlines():
|
|
80
|
+
stripped = line.strip()
|
|
81
|
+
if stripped.startswith("@"):
|
|
82
|
+
pending |= set(_TAG_RE.findall(stripped))
|
|
83
|
+
continue
|
|
84
|
+
m = _SCENARIO_RE.match(line)
|
|
85
|
+
if m:
|
|
86
|
+
found.append(
|
|
87
|
+
_Scenario(fp.name, m.group(1), frozenset(pending))
|
|
88
|
+
)
|
|
89
|
+
pending = set()
|
|
90
|
+
elif stripped and not stripped.startswith("#"):
|
|
91
|
+
pending = set()
|
|
92
|
+
self._scenarios = found
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
def _load_results(self) -> dict[str, bool]:
|
|
96
|
+
"""Map scenario name -> passed, from a cucumber-json report if configured."""
|
|
97
|
+
if self._results is not None:
|
|
98
|
+
return self._results
|
|
99
|
+
out: dict[str, bool] = {}
|
|
100
|
+
if self._results_path:
|
|
101
|
+
rp = self._root / self._results_path
|
|
102
|
+
if rp.exists():
|
|
103
|
+
data: object = json.loads(rp.read_text(encoding="utf-8"))
|
|
104
|
+
if isinstance(data, list):
|
|
105
|
+
for feature in cast("list[object]", data):
|
|
106
|
+
if not isinstance(feature, dict):
|
|
107
|
+
continue
|
|
108
|
+
elements = cast("dict[str, object]", feature).get("elements")
|
|
109
|
+
if not isinstance(elements, list):
|
|
110
|
+
continue
|
|
111
|
+
for el in cast("list[object]", elements):
|
|
112
|
+
if not isinstance(el, dict):
|
|
113
|
+
continue
|
|
114
|
+
el_d = cast("dict[str, object]", el)
|
|
115
|
+
name = el_d.get("name")
|
|
116
|
+
steps = el_d.get("steps")
|
|
117
|
+
if isinstance(name, str) and isinstance(steps, list):
|
|
118
|
+
out[name] = _steps_passed(cast("list[object]", steps))
|
|
119
|
+
self._results = out
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
123
|
+
scenarios = self._load()
|
|
124
|
+
if payload.startswith("tag:"):
|
|
125
|
+
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
|
+
]
|
|
132
|
+
if not matches:
|
|
133
|
+
return ProofResult(ProofState.BROKEN, False, f"no scenario matches '{payload}'")
|
|
134
|
+
|
|
135
|
+
results = self._load_results()
|
|
136
|
+
if results:
|
|
137
|
+
states = [results.get(s.title) for s in matches]
|
|
138
|
+
if all(v is True for v in states):
|
|
139
|
+
return ProofResult(ProofState.DELIVERED, False, "results: passed")
|
|
140
|
+
if any(v is False for v in states):
|
|
141
|
+
return ProofResult(ProofState.PENDING, False, "results: failing")
|
|
142
|
+
# matched scenarios absent from the report → declared only
|
|
143
|
+
tags: frozenset[str] = frozenset()
|
|
144
|
+
for s in matches:
|
|
145
|
+
tags |= s.tags
|
|
146
|
+
if tags & self._delivered:
|
|
147
|
+
return ProofResult(ProofState.DELIVERED, True, "tagged delivered")
|
|
148
|
+
if tags & self._pending:
|
|
149
|
+
return ProofResult(ProofState.PENDING, True, "tagged pending")
|
|
150
|
+
return ProofResult(ProofState.PENDING, True, "found, untagged")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PytestAdapter:
|
|
154
|
+
"""Resolves ``path::test_name`` by existence, upgraded by a junit report if given."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, root: Path, results: str | None) -> None:
|
|
157
|
+
self._root = root
|
|
158
|
+
self._results_path = results
|
|
159
|
+
self._results: dict[str, bool] | None = None
|
|
160
|
+
|
|
161
|
+
def _load_results(self) -> dict[str, bool]:
|
|
162
|
+
if self._results is not None:
|
|
163
|
+
return self._results
|
|
164
|
+
out: dict[str, bool] = {}
|
|
165
|
+
if self._results_path:
|
|
166
|
+
rp = self._root / self._results_path
|
|
167
|
+
if rp.exists():
|
|
168
|
+
tree = ET.parse(rp)
|
|
169
|
+
for case in tree.iter("testcase"):
|
|
170
|
+
name = case.get("name", "")
|
|
171
|
+
failed = any(c.tag in ("failure", "error") for c in case)
|
|
172
|
+
skipped = any(c.tag == "skipped" for c in case)
|
|
173
|
+
if name:
|
|
174
|
+
out[name] = not failed and not skipped
|
|
175
|
+
self._results = out
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
179
|
+
file_part, _, node = payload.partition("::")
|
|
180
|
+
func = node.split("[")[0] # strip parametrisation
|
|
181
|
+
fp = self._root / file_part
|
|
182
|
+
if not fp.exists():
|
|
183
|
+
return ProofResult(ProofState.BROKEN, False, f"no file '{file_part}'")
|
|
184
|
+
if func and f"def {func}" not in fp.read_text(encoding="utf-8"):
|
|
185
|
+
return ProofResult(ProofState.BROKEN, False, f"no test '{func}' in {file_part}")
|
|
186
|
+
results = self._load_results()
|
|
187
|
+
if func in results:
|
|
188
|
+
ok = results[func]
|
|
189
|
+
return ProofResult(
|
|
190
|
+
ProofState.DELIVERED if ok else ProofState.PENDING,
|
|
191
|
+
False,
|
|
192
|
+
"junit: passed" if ok else "junit: failing",
|
|
193
|
+
)
|
|
194
|
+
return ProofResult(ProofState.DELIVERED, True, "test exists")
|
prodc/cli.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""``prodc`` command line: one verb, ``status`` — the board.
|
|
2
|
+
|
|
3
|
+
Prints the gap between needs and what the test suite proves, sorted by what needs
|
|
4
|
+
attention first. Exit 1 on a BROKEN proof; ``--strict`` also on an opinion. A HOLE
|
|
5
|
+
never fails — it is the honest gap you want people to keep writing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import Config, ConfigError
|
|
17
|
+
from .loader import LoaderError, load_needs
|
|
18
|
+
from .model import Need, NeedResult, NeedState, SourceKind
|
|
19
|
+
from .status import Summary, resolve, summarize
|
|
20
|
+
|
|
21
|
+
_LABEL = {
|
|
22
|
+
NeedState.DELIVERED: "DELIVERED",
|
|
23
|
+
NeedState.PENDING: "PENDING",
|
|
24
|
+
NeedState.HOLE: "HOLE",
|
|
25
|
+
NeedState.BROKEN: "BROKEN",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _priority(r: NeedResult) -> int:
|
|
30
|
+
if r.need.must and r.state is NeedState.HOLE:
|
|
31
|
+
return 0
|
|
32
|
+
return {
|
|
33
|
+
NeedState.BROKEN: 1,
|
|
34
|
+
NeedState.PENDING: 2,
|
|
35
|
+
NeedState.HOLE: 3,
|
|
36
|
+
NeedState.DELIVERED: 4,
|
|
37
|
+
}[r.state]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Warrant strength, strongest first: interview > artifact > internal (Torres).
|
|
41
|
+
_WARRANT_ORDER = (SourceKind.INTERVIEW, SourceKind.ARTIFACT, SourceKind.INTERNAL)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _warrant(need: Need) -> str:
|
|
45
|
+
"""The warrant column: the strongest source kind, or 'opinion' when unsourced."""
|
|
46
|
+
if need.is_opinion:
|
|
47
|
+
return "opinion"
|
|
48
|
+
kinds = {s.kind for s in need.sources}
|
|
49
|
+
strongest = next(k for k in _WARRANT_ORDER if k in kinds)
|
|
50
|
+
if strongest is SourceKind.INTERVIEW:
|
|
51
|
+
who = next((s.who for s in need.sources if s.kind is SourceKind.INTERVIEW and s.who), None)
|
|
52
|
+
return f"interview:{who}" if who else "interview"
|
|
53
|
+
return strongest.value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _summary_line(s: Summary) -> str:
|
|
57
|
+
"""The leading gap-count line: what needs attention, and overall coverage."""
|
|
58
|
+
parts: list[str] = []
|
|
59
|
+
if s.must_hole:
|
|
60
|
+
parts.append(f"{s.must_hole} must-HOLE")
|
|
61
|
+
if s.broken:
|
|
62
|
+
parts.append(f"{s.broken} BROKEN")
|
|
63
|
+
if s.pending:
|
|
64
|
+
parts.append(f"{s.pending} PENDING")
|
|
65
|
+
if s.hole - s.must_hole:
|
|
66
|
+
parts.append(f"{s.hole - s.must_hole} HOLE")
|
|
67
|
+
if s.opinion:
|
|
68
|
+
parts.append(f"{s.opinion} opinion")
|
|
69
|
+
gaps = " · ".join(parts) if parts else "no gaps"
|
|
70
|
+
return f"gaps: {gaps} coverage {s.delivered}/{s.total} ({round(s.coverage * 100)}%)"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render(results: list[NeedResult]) -> str:
|
|
74
|
+
groups: dict[str, list[NeedResult]] = {}
|
|
75
|
+
for r in results:
|
|
76
|
+
groups.setdefault(r.need.who or "(unassigned)", []).append(r)
|
|
77
|
+
|
|
78
|
+
out: list[str] = []
|
|
79
|
+
# The Toulmin split: GROUNDS (does a test prove it?) vs WARRANT (user signal it
|
|
80
|
+
# 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")
|
|
82
|
+
for who in sorted(groups):
|
|
83
|
+
rs = sorted(groups[who], key=_priority)
|
|
84
|
+
label = next((x.need.label for x in rs if x.need.label), None)
|
|
85
|
+
counts = {s: sum(1 for r in rs if r.state is s) for s in NeedState}
|
|
86
|
+
head = " · ".join(f"{counts[s]} {_LABEL[s]}" for s in NeedState if counts[s])
|
|
87
|
+
title = f"{who}" + (f" ({label})" if label else "")
|
|
88
|
+
out.append(f"\n{title} {head}")
|
|
89
|
+
for r in rs:
|
|
90
|
+
bang = "!!" if r.need.must else " "
|
|
91
|
+
star = "*" if r.declared and r.state is NeedState.DELIVERED else " "
|
|
92
|
+
nm = f"{r.delivered}/{r.total}" if r.total else ""
|
|
93
|
+
out.append(
|
|
94
|
+
f" {bang} {r.need.id:<9} {_LABEL[r.state]:<9}{star} {nm:<5} "
|
|
95
|
+
f"{r.need.text[:48]:<48} {_warrant(r.need)}"
|
|
96
|
+
)
|
|
97
|
+
for w in r.warnings:
|
|
98
|
+
out.append(f" ⚠ {w}")
|
|
99
|
+
return "\n".join(out)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _to_json(results: list[NeedResult], summary: Summary) -> str:
|
|
103
|
+
payload = {
|
|
104
|
+
"summary": {
|
|
105
|
+
"total": summary.total,
|
|
106
|
+
"delivered": summary.delivered,
|
|
107
|
+
"pending": summary.pending,
|
|
108
|
+
"hole": summary.hole,
|
|
109
|
+
"must_hole": summary.must_hole,
|
|
110
|
+
"broken": summary.broken,
|
|
111
|
+
"opinion": summary.opinion,
|
|
112
|
+
"coverage": round(summary.coverage, 4),
|
|
113
|
+
},
|
|
114
|
+
"needs": [
|
|
115
|
+
{
|
|
116
|
+
"id": r.need.id,
|
|
117
|
+
"who": r.need.who,
|
|
118
|
+
"text": r.need.text,
|
|
119
|
+
"must": r.need.must,
|
|
120
|
+
"state": r.state.value,
|
|
121
|
+
"grounds": r.state.value,
|
|
122
|
+
"warrant": _warrant(r.need),
|
|
123
|
+
"delivered": r.delivered,
|
|
124
|
+
"total": r.total,
|
|
125
|
+
"declared": r.declared,
|
|
126
|
+
"opinion": r.need.is_opinion,
|
|
127
|
+
"only_internal": r.need.only_internal,
|
|
128
|
+
"detail": r.detail,
|
|
129
|
+
"warnings": r.warnings,
|
|
130
|
+
}
|
|
131
|
+
for r in results
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
return json.dumps(payload, indent=2)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
138
|
+
cfg_path = Path(args.config)
|
|
139
|
+
if not cfg_path.exists():
|
|
140
|
+
print(f"prodc: no config at {cfg_path}", file=sys.stderr)
|
|
141
|
+
return 2
|
|
142
|
+
try:
|
|
143
|
+
cfg = Config.load(cfg_path)
|
|
144
|
+
needs = load_needs(cfg)
|
|
145
|
+
except (ConfigError, LoaderError) as exc:
|
|
146
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
results = resolve(needs, cfg.adapters)
|
|
149
|
+
summary = summarize(results)
|
|
150
|
+
|
|
151
|
+
if args.json:
|
|
152
|
+
print(_to_json(results, summary))
|
|
153
|
+
else:
|
|
154
|
+
commit = _git_rev(cfg.root)
|
|
155
|
+
print(f"prodc status — {cfg.root.name}{f' @ {commit}' if commit else ''}")
|
|
156
|
+
print(_summary_line(summary))
|
|
157
|
+
print(_render(results))
|
|
158
|
+
|
|
159
|
+
return _exit_code(args, summary)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _exit_code(args: argparse.Namespace, summary: Summary) -> int:
|
|
163
|
+
"""Gate: 0 unless a threshold is crossed; HOLE never fails.
|
|
164
|
+
|
|
165
|
+
Default fails on any BROKEN; ``--max-dangling N`` tolerates N; ``--min-coverage F``
|
|
166
|
+
fails when delivered/total < F; ``--strict`` fails on any opinion.
|
|
167
|
+
"""
|
|
168
|
+
if args.max_dangling is not None:
|
|
169
|
+
if summary.broken > args.max_dangling:
|
|
170
|
+
return 1
|
|
171
|
+
elif summary.broken:
|
|
172
|
+
return 1
|
|
173
|
+
if args.min_coverage is not None and summary.coverage < args.min_coverage:
|
|
174
|
+
return 1
|
|
175
|
+
if args.strict and summary.opinion:
|
|
176
|
+
return 1
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _git_rev(root: Path) -> str | None:
|
|
181
|
+
head = root / ".git" / "HEAD"
|
|
182
|
+
try:
|
|
183
|
+
ref = head.read_text(encoding="utf-8").strip()
|
|
184
|
+
except OSError:
|
|
185
|
+
return None
|
|
186
|
+
if ref.startswith("ref:"):
|
|
187
|
+
target = root / ".git" / ref[4:].strip()
|
|
188
|
+
try:
|
|
189
|
+
return target.read_text(encoding="utf-8").strip()[:7]
|
|
190
|
+
except OSError:
|
|
191
|
+
return None
|
|
192
|
+
return ref[:7]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv: list[str] | None = None) -> int:
|
|
196
|
+
parser = argparse.ArgumentParser(prog="prodc", description="Product management as code.")
|
|
197
|
+
parser.add_argument("--version", action="version", version=f"prodc {__version__}")
|
|
198
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
199
|
+
st = sub.add_parser("status", help="show the needs board")
|
|
200
|
+
st.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
201
|
+
st.add_argument("--json", action="store_true", help="machine-readable output")
|
|
202
|
+
st.add_argument("--strict", action="store_true", help="also exit 1 on opinions")
|
|
203
|
+
st.add_argument(
|
|
204
|
+
"--min-coverage",
|
|
205
|
+
type=float,
|
|
206
|
+
default=None,
|
|
207
|
+
metavar="FRAC",
|
|
208
|
+
help="gate: exit 1 if delivered/total < FRAC (0..1)",
|
|
209
|
+
)
|
|
210
|
+
st.add_argument(
|
|
211
|
+
"--max-dangling",
|
|
212
|
+
type=int,
|
|
213
|
+
default=None,
|
|
214
|
+
metavar="N",
|
|
215
|
+
help="gate: tolerate up to N broken locators instead of failing on any",
|
|
216
|
+
)
|
|
217
|
+
st.set_defaults(func=_cmd_status)
|
|
218
|
+
|
|
219
|
+
args = parser.parse_args(argv)
|
|
220
|
+
return int(args.func(args))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
raise SystemExit(main())
|
prodc/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Load ``prodc.toml``: where the needs live and how each proof adapter resolves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from .adapters import Adapter, GherkinAdapter, PytestAdapter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _as_dict(value: object, where: str) -> dict[str, object]:
|
|
18
|
+
if not isinstance(value, dict):
|
|
19
|
+
raise ConfigError(f"{where}: expected a table")
|
|
20
|
+
return cast("dict[str, object]", value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _as_str_list(value: object, where: str) -> list[str]:
|
|
24
|
+
if not isinstance(value, list):
|
|
25
|
+
raise ConfigError(f"{where}: expected a list of strings")
|
|
26
|
+
items = cast("list[object]", value)
|
|
27
|
+
if not all(isinstance(x, str) for x in items):
|
|
28
|
+
raise ConfigError(f"{where}: expected a list of strings")
|
|
29
|
+
return [x for x in items if isinstance(x, str)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Config:
|
|
34
|
+
root: Path
|
|
35
|
+
need_globs: list[str]
|
|
36
|
+
adapters: dict[str, Adapter] = field(default_factory=dict[str, Adapter])
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def load(cls, path: Path) -> Config:
|
|
40
|
+
root = path.parent
|
|
41
|
+
raw = _as_dict(tomllib.loads(path.read_text(encoding="utf-8")), "prodc.toml")
|
|
42
|
+
|
|
43
|
+
prodc_tbl = _as_dict(raw.get("prodc", {}), "[prodc]")
|
|
44
|
+
need_globs = _as_str_list(prodc_tbl.get("needs", []), "[prodc].needs")
|
|
45
|
+
if not need_globs:
|
|
46
|
+
raise ConfigError("[prodc].needs must list at least one glob")
|
|
47
|
+
|
|
48
|
+
adapters: dict[str, Adapter] = {}
|
|
49
|
+
proofs = _as_dict(raw.get("proofs", {}), "[proofs]")
|
|
50
|
+
for name, body in proofs.items():
|
|
51
|
+
tbl = _as_dict(body, f"[proofs.{name}]")
|
|
52
|
+
kind = tbl.get("kind")
|
|
53
|
+
results = tbl.get("results")
|
|
54
|
+
results_s = results if isinstance(results, str) else None
|
|
55
|
+
if kind == "gherkin":
|
|
56
|
+
adapters[name] = GherkinAdapter(
|
|
57
|
+
root=root,
|
|
58
|
+
paths=_as_str_list(tbl.get("paths", []), f"[proofs.{name}].paths"),
|
|
59
|
+
delivered_tags=_as_str_list(
|
|
60
|
+
tbl.get("delivered", []), f"[proofs.{name}].delivered"
|
|
61
|
+
),
|
|
62
|
+
pending_tags=_as_str_list(
|
|
63
|
+
tbl.get("pending", []), f"[proofs.{name}].pending"
|
|
64
|
+
),
|
|
65
|
+
results=results_s,
|
|
66
|
+
)
|
|
67
|
+
elif kind == "pytest":
|
|
68
|
+
sub = tbl.get("root", ".")
|
|
69
|
+
adapters[name] = PytestAdapter(
|
|
70
|
+
root=root / (sub if isinstance(sub, str) else "."),
|
|
71
|
+
results=results_s,
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
raise ConfigError(f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest)")
|
|
75
|
+
return cls(root=root, need_globs=need_globs, adapters=adapters)
|
prodc/loader.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Load need files (YAML) into :class:`~prodc.model.Need` objects.
|
|
2
|
+
|
|
3
|
+
A file-level ``who``/``label`` default every need in it; a need may override either.
|
|
4
|
+
IDs are globally unique (children reference ids across files), so a duplicate is a
|
|
5
|
+
:class:`LoaderError`. A flow (need with ``children``) must not also carry ``proofs``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .model import Need, Source, SourceKind
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoaderError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_mapping(value: object, where: str) -> dict[str, object]:
|
|
25
|
+
if not isinstance(value, dict):
|
|
26
|
+
raise LoaderError(f"{where}: expected a mapping")
|
|
27
|
+
return cast("dict[str, object]", value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _as_list(value: object, where: str) -> list[object]:
|
|
31
|
+
if value is None:
|
|
32
|
+
return []
|
|
33
|
+
if not isinstance(value, list):
|
|
34
|
+
raise LoaderError(f"{where}: expected a list")
|
|
35
|
+
return cast("list[object]", value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _as_str(value: object, where: str) -> str:
|
|
39
|
+
if not isinstance(value, str):
|
|
40
|
+
raise LoaderError(f"{where}: expected a string")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _opt_str(value: object, where: str) -> str | None:
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
# A bare ``date: 2026-09-20`` parses as a date, not a string — accept it.
|
|
48
|
+
if isinstance(value, datetime.date):
|
|
49
|
+
return value.isoformat()
|
|
50
|
+
return _as_str(value, where)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_bool(value: object, where: str) -> bool:
|
|
54
|
+
if not isinstance(value, bool):
|
|
55
|
+
raise LoaderError(f"{where}: expected a boolean")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _source(raw: object, where: str) -> Source:
|
|
60
|
+
tbl = _as_mapping(raw, where)
|
|
61
|
+
kind_name = _opt_str(tbl.get("kind"), f"{where}.kind") or SourceKind.INTERNAL.value
|
|
62
|
+
try:
|
|
63
|
+
kind = SourceKind(kind_name)
|
|
64
|
+
except ValueError:
|
|
65
|
+
kinds = "|".join(k.value for k in SourceKind)
|
|
66
|
+
raise LoaderError(f"{where}.kind: unknown kind {kind_name!r} ({kinds})") from None
|
|
67
|
+
return Source(
|
|
68
|
+
kind=kind,
|
|
69
|
+
who=_opt_str(tbl.get("who"), f"{where}.who"),
|
|
70
|
+
date=_opt_str(tbl.get("date"), f"{where}.date"),
|
|
71
|
+
quote=_opt_str(tbl.get("quote"), f"{where}.quote"),
|
|
72
|
+
locator=_opt_str(tbl.get("locator"), f"{where}.locator"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _need(raw: object, where: str, file_who: str | None, file_label: str | None) -> Need:
|
|
77
|
+
tbl = _as_mapping(raw, where)
|
|
78
|
+
nid = _as_str(tbl.get("id"), f"{where}.id")
|
|
79
|
+
text = _as_str(tbl.get("text"), f"{where}.text")
|
|
80
|
+
must = _as_bool(tbl["must"], f"{where}.must") if "must" in tbl else False
|
|
81
|
+
who = _opt_str(tbl.get("who"), f"{where}.who") or file_who
|
|
82
|
+
label = _opt_str(tbl.get("label"), f"{where}.label") or file_label
|
|
83
|
+
|
|
84
|
+
sources = tuple(
|
|
85
|
+
_source(s, f"{where}.sources[{i}]")
|
|
86
|
+
for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
|
|
87
|
+
)
|
|
88
|
+
proofs = tuple(
|
|
89
|
+
_as_str(p, f"{where}.proofs[{i}]")
|
|
90
|
+
for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
|
|
91
|
+
)
|
|
92
|
+
children = tuple(
|
|
93
|
+
_as_str(c, f"{where}.children[{i}]")
|
|
94
|
+
for i, c in enumerate(_as_list(tbl.get("children"), f"{where}.children"))
|
|
95
|
+
)
|
|
96
|
+
if children and proofs:
|
|
97
|
+
raise LoaderError(f"{where} ({nid}): a flow (need with children) must not carry proofs")
|
|
98
|
+
|
|
99
|
+
return Need(
|
|
100
|
+
id=nid,
|
|
101
|
+
text=text,
|
|
102
|
+
who=who,
|
|
103
|
+
must=must,
|
|
104
|
+
sources=sources,
|
|
105
|
+
proofs=proofs,
|
|
106
|
+
children=children,
|
|
107
|
+
label=label,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_file(path: Path, seen: dict[str, Path]) -> list[Need]:
|
|
112
|
+
where = path.name
|
|
113
|
+
try:
|
|
114
|
+
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
115
|
+
except yaml.YAMLError as exc:
|
|
116
|
+
raise LoaderError(f"{where}: invalid YAML: {exc}") from exc
|
|
117
|
+
if doc is None:
|
|
118
|
+
return []
|
|
119
|
+
tbl = _as_mapping(doc, where)
|
|
120
|
+
file_who = _opt_str(tbl.get("who"), f"{where}.who")
|
|
121
|
+
file_label = _opt_str(tbl.get("label"), f"{where}.label")
|
|
122
|
+
|
|
123
|
+
out: list[Need] = []
|
|
124
|
+
for i, raw in enumerate(_as_list(tbl.get("needs"), f"{where}.needs")):
|
|
125
|
+
need = _need(raw, f"{where}.needs[{i}]", file_who, file_label)
|
|
126
|
+
prior = seen.get(need.id)
|
|
127
|
+
if prior is not None:
|
|
128
|
+
raise LoaderError(f"duplicate need id {need.id!r} in {where} and {prior.name}")
|
|
129
|
+
seen[need.id] = path
|
|
130
|
+
out.append(need)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_needs(config: Config) -> list[Need]:
|
|
135
|
+
"""Resolve every need glob in ``config`` to a flat, id-unique list of needs."""
|
|
136
|
+
needs: list[Need] = []
|
|
137
|
+
seen: dict[str, Path] = {}
|
|
138
|
+
for pattern in config.need_globs:
|
|
139
|
+
for path in sorted(config.root.glob(pattern)):
|
|
140
|
+
needs.extend(_load_file(path, seen))
|
|
141
|
+
return needs
|
prodc/model.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The prodc data model: Need, Source, and the derived status states.
|
|
2
|
+
|
|
3
|
+
Three nouns (Torres): a Need is what a named person wants (an opportunity); a
|
|
4
|
+
Source is its provenance (evidence) — a Need with no Source is an opinion; a proof
|
|
5
|
+
is a locator to a delivery test, resolved by an adapter. Everything else is
|
|
6
|
+
expressed with these: a flow is a Need with children, a persona is ``who``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SourceKind(StrEnum):
|
|
16
|
+
"""How a need was evidenced, in descending epistemic weight (Torres, Ch. 5)."""
|
|
17
|
+
|
|
18
|
+
INTERVIEW = "interview" # behavioural evidence: a named customer, a date, a verbatim quote
|
|
19
|
+
ARTIFACT = "artifact" # documentary: a ticket, doc or email locator
|
|
20
|
+
INTERNAL = "internal" # a team member's own assertion — provenance, but not a customer's
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProofState(StrEnum):
|
|
24
|
+
"""The state of a single proof locator, reported by its adapter."""
|
|
25
|
+
|
|
26
|
+
DELIVERED = "delivered" # the test exists and (if a results artifact is present) passes
|
|
27
|
+
PENDING = "pending" # declared/planned, not yet green
|
|
28
|
+
BROKEN = "broken" # the locator does not resolve to anything
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NeedState(StrEnum):
|
|
32
|
+
"""The rolled-up state of a Need (its proofs, or its children)."""
|
|
33
|
+
|
|
34
|
+
DELIVERED = "delivered"
|
|
35
|
+
PENDING = "pending"
|
|
36
|
+
HOLE = "hole" # no proof at all — an honest gap, never a build failure
|
|
37
|
+
BROKEN = "broken" # a proof locator points at nothing
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Source:
|
|
42
|
+
"""Where a need came from. Absence of any Source makes the need an opinion."""
|
|
43
|
+
|
|
44
|
+
kind: SourceKind
|
|
45
|
+
who: str | None = None
|
|
46
|
+
date: str | None = None
|
|
47
|
+
quote: str | None = None
|
|
48
|
+
locator: str | None = None
|
|
49
|
+
|
|
50
|
+
def warnings(self) -> list[str]:
|
|
51
|
+
"""Soft, Torres-derived hygiene checks — never fail the build."""
|
|
52
|
+
out: list[str] = []
|
|
53
|
+
if self.kind is SourceKind.INTERVIEW and not self.quote:
|
|
54
|
+
out.append("interview source without a verbatim quote")
|
|
55
|
+
if self.kind is SourceKind.INTERVIEW and not self.who:
|
|
56
|
+
out.append("interview source without a named person")
|
|
57
|
+
if self.kind is SourceKind.ARTIFACT and not self.locator:
|
|
58
|
+
out.append("artifact source without a locator")
|
|
59
|
+
if not self.date:
|
|
60
|
+
out.append("source without a date")
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Need:
|
|
66
|
+
"""What a named person wants, with its provenance and its proofs."""
|
|
67
|
+
|
|
68
|
+
id: str
|
|
69
|
+
text: str
|
|
70
|
+
who: str | None = None
|
|
71
|
+
must: bool = False
|
|
72
|
+
sources: tuple[Source, ...] = ()
|
|
73
|
+
proofs: tuple[str, ...] = () # locators, "<adapter>:<payload>"
|
|
74
|
+
children: tuple[str, ...] = () # ids of sub-needs (a flow is a Need with children)
|
|
75
|
+
label: str | None = None # free text for the `who`, e.g. "design partner, Sincerely"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_opinion(self) -> bool:
|
|
79
|
+
"""No source of any kind = opinion (Torres: an unvalidated desirability assumption)."""
|
|
80
|
+
return len(self.sources) == 0
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def only_internal(self) -> bool:
|
|
84
|
+
"""Sourced, but only by the team's own assertion — not a customer."""
|
|
85
|
+
return len(self.sources) > 0 and all(s.kind is SourceKind.INTERNAL for s in self.sources)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# A leading verb that usually signals a solution-in-disguise, not a need (Torres, Ch. 6).
|
|
89
|
+
SOLUTION_VERBS = (
|
|
90
|
+
"implement",
|
|
91
|
+
"add",
|
|
92
|
+
"build",
|
|
93
|
+
"create",
|
|
94
|
+
"support",
|
|
95
|
+
"integrate",
|
|
96
|
+
"redesign",
|
|
97
|
+
"refactor",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class NeedResult:
|
|
103
|
+
"""A resolved need: its state, its proof detail, and any hygiene warnings."""
|
|
104
|
+
|
|
105
|
+
need: Need
|
|
106
|
+
state: NeedState
|
|
107
|
+
delivered: int = 0
|
|
108
|
+
total: int = 0
|
|
109
|
+
declared: bool = False # true if DELIVERED rests on tags/existence, not a results artifact
|
|
110
|
+
detail: list[str] = field(default_factory=list[str])
|
|
111
|
+
warnings: list[str] = field(default_factory=list[str])
|
prodc/py.typed
ADDED
|
File without changes
|
prodc/status.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Resolve needs into states: the engine behind ``prodc status``.
|
|
2
|
+
|
|
3
|
+
A leaf's state comes from its proofs; a flow (need with children) aggregates its
|
|
4
|
+
sub-needs. ``HOLE`` (no proof) is deliberately not a failure — it is an honest gap.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .adapters import Adapter, ProofResult
|
|
12
|
+
from .model import (
|
|
13
|
+
SOLUTION_VERBS,
|
|
14
|
+
Need,
|
|
15
|
+
NeedResult,
|
|
16
|
+
NeedState,
|
|
17
|
+
ProofState,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _text_warnings(need: Need) -> list[str]:
|
|
22
|
+
first = need.text.strip().split(" ", 1)[0].lower().rstrip(":")
|
|
23
|
+
out: list[str] = []
|
|
24
|
+
if first in SOLUTION_VERBS:
|
|
25
|
+
out.append(f"text may be a solution in disguise (starts with '{first}')")
|
|
26
|
+
for src in need.sources:
|
|
27
|
+
out.extend(src.warnings())
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_proof(locator: str, adapters: dict[str, Adapter]) -> ProofResult:
|
|
32
|
+
name, sep, payload = locator.partition(":")
|
|
33
|
+
adapter = adapters.get(name)
|
|
34
|
+
if not sep or adapter is None:
|
|
35
|
+
return ProofResult(ProofState.BROKEN, False, f"no adapter '{name}' for '{locator}'")
|
|
36
|
+
return adapter.resolve(payload)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Resolver:
|
|
40
|
+
def __init__(self, needs: list[Need], adapters: dict[str, Adapter]) -> None:
|
|
41
|
+
self._by_id = {n.id: n for n in needs}
|
|
42
|
+
self._adapters = adapters
|
|
43
|
+
self._cache: dict[str, NeedResult] = {}
|
|
44
|
+
self._visiting: set[str] = set()
|
|
45
|
+
|
|
46
|
+
def resolve_all(self) -> list[NeedResult]:
|
|
47
|
+
return [self.resolve(n.id) for n in self._by_id.values()]
|
|
48
|
+
|
|
49
|
+
def resolve(self, need_id: str) -> NeedResult:
|
|
50
|
+
if need_id in self._cache:
|
|
51
|
+
return self._cache[need_id]
|
|
52
|
+
need = self._by_id[need_id]
|
|
53
|
+
if need_id in self._visiting: # cycle — stop and report
|
|
54
|
+
return NeedResult(need, NeedState.BROKEN, detail=["cycle in children"])
|
|
55
|
+
self._visiting.add(need_id)
|
|
56
|
+
result = self._compute(need)
|
|
57
|
+
self._visiting.discard(need_id)
|
|
58
|
+
result.warnings = _text_warnings(need) + result.warnings
|
|
59
|
+
self._cache[need_id] = result
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
def _compute(self, need: Need) -> NeedResult:
|
|
63
|
+
if need.children:
|
|
64
|
+
return self._aggregate_children(need)
|
|
65
|
+
return self._resolve_leaf(need)
|
|
66
|
+
|
|
67
|
+
def _resolve_leaf(self, need: Need) -> NeedResult:
|
|
68
|
+
if not need.proofs:
|
|
69
|
+
return NeedResult(need, NeedState.HOLE)
|
|
70
|
+
results = [_resolve_proof(p, self._adapters) for p in need.proofs]
|
|
71
|
+
detail = [f"{p} → {r.state.value}{'*' if r.declared else ''} ({r.detail})"
|
|
72
|
+
for p, r in zip(need.proofs, results, strict=True)]
|
|
73
|
+
delivered = sum(1 for r in results if r.state is ProofState.DELIVERED)
|
|
74
|
+
declared = any(r.declared for r in results if r.state is ProofState.DELIVERED)
|
|
75
|
+
if any(r.state is ProofState.BROKEN for r in results):
|
|
76
|
+
state = NeedState.BROKEN
|
|
77
|
+
elif delivered == len(results):
|
|
78
|
+
state = NeedState.DELIVERED
|
|
79
|
+
else:
|
|
80
|
+
state = NeedState.PENDING
|
|
81
|
+
return NeedResult(need, state, delivered, len(results), declared, detail)
|
|
82
|
+
|
|
83
|
+
def _aggregate_children(self, need: Need) -> NeedResult:
|
|
84
|
+
warnings: list[str] = []
|
|
85
|
+
child_states: list[NeedState] = []
|
|
86
|
+
declared = False
|
|
87
|
+
for cid in need.children:
|
|
88
|
+
if cid not in self._by_id:
|
|
89
|
+
warnings.append(f"unknown child id '{cid}'")
|
|
90
|
+
continue
|
|
91
|
+
cr = self.resolve(cid)
|
|
92
|
+
child_states.append(cr.state)
|
|
93
|
+
declared = declared or cr.declared
|
|
94
|
+
delivered = sum(1 for s in child_states if s is NeedState.DELIVERED)
|
|
95
|
+
total = len(child_states)
|
|
96
|
+
if any(s is NeedState.BROKEN for s in child_states):
|
|
97
|
+
state = NeedState.BROKEN
|
|
98
|
+
elif total and delivered == total:
|
|
99
|
+
state = NeedState.DELIVERED
|
|
100
|
+
elif not child_states or all(s is NeedState.HOLE for s in child_states):
|
|
101
|
+
state = NeedState.HOLE
|
|
102
|
+
else:
|
|
103
|
+
state = NeedState.PENDING
|
|
104
|
+
detail = [f"{delivered} of {total} sub-needs delivered"]
|
|
105
|
+
return NeedResult(need, state, delivered, total, declared, detail, warnings)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve(needs: list[Need], adapters: dict[str, Adapter]) -> list[NeedResult]:
|
|
109
|
+
"""Resolve every need to a :class:`NeedResult`."""
|
|
110
|
+
return Resolver(needs, adapters).resolve_all()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Summary:
|
|
115
|
+
"""The gap at a glance: how many needs sit in each state, and how many are opinions."""
|
|
116
|
+
|
|
117
|
+
total: int
|
|
118
|
+
delivered: int
|
|
119
|
+
pending: int
|
|
120
|
+
hole: int
|
|
121
|
+
must_hole: int # a HOLE on a must-have — the top of the board
|
|
122
|
+
broken: int # a dangling proof locator
|
|
123
|
+
opinion: int # a need with no source of any kind
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def coverage(self) -> float:
|
|
127
|
+
"""Fraction of needs that are DELIVERED (0.0 when there are no needs)."""
|
|
128
|
+
return self.delivered / self.total if self.total else 0.0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def summarize(results: list[NeedResult]) -> Summary:
|
|
132
|
+
"""Roll a resolved board up into its gap counts."""
|
|
133
|
+
by_state = {s: sum(1 for r in results if r.state is s) for s in NeedState}
|
|
134
|
+
return Summary(
|
|
135
|
+
total=len(results),
|
|
136
|
+
delivered=by_state[NeedState.DELIVERED],
|
|
137
|
+
pending=by_state[NeedState.PENDING],
|
|
138
|
+
hole=by_state[NeedState.HOLE],
|
|
139
|
+
must_hole=sum(1 for r in results if r.need.must and r.state is NeedState.HOLE),
|
|
140
|
+
broken=by_state[NeedState.BROKEN],
|
|
141
|
+
opinion=sum(1 for r in results if r.need.is_opinion),
|
|
142
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: prodc
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Product management as code: personas, flows, and features traced to evidence.
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
|
|
7
|
+
Project-URL: Changelog, https://gitlab.com/jorgeecardona/prodc/-/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Jorge Cardona <jorgeecardona@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: pyyaml>=6
|
|
21
|
+
Provides-Extra: docs
|
|
22
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
23
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
24
|
+
Requires-Dist: mkdocstrings[python]>=0.27; extra == 'docs'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# prodc
|
|
28
|
+
|
|
29
|
+
Product management as code: personas, flows, and features traced to evidence, so a
|
|
30
|
+
product's state is read from the repo instead of chased in meetings.
|
|
31
|
+
|
|
32
|
+
prodc treats product thinking the way requirements-as-code tools (Doorstop, StrictDoc)
|
|
33
|
+
treat requirements — plain text, in git, with IDs that link to each other — but for the
|
|
34
|
+
artifacts a product person actually works with: **Persona → Flow → Feature → Assumption**,
|
|
35
|
+
each tied to **evidence**. Two rules give it teeth:
|
|
36
|
+
|
|
37
|
+
- **No evidence link = flagged as opinion.** Every "why" must trace to a user signal
|
|
38
|
+
(feedback, ticket, interview), or it is marked unsupported.
|
|
39
|
+
- **Status comes from the repo, not from a standup.** "Flow UF-004: 3 of 5 scenarios
|
|
40
|
+
passing", "Feature F-012: blocked, assumption A-003 has no data" — the report answers
|
|
41
|
+
"where are we" and "what's blocking" without a meeting.
|
|
42
|
+
|
|
43
|
+
The lineage is Toulmin's argument model (claim + grounds + warrant): a feature is a claim,
|
|
44
|
+
its evidence is the grounds, its assumptions are the warrant. The same shape safety
|
|
45
|
+
engineering uses for a "safety case", pointed at product decisions.
|
|
46
|
+
|
|
47
|
+
> Status: v0, early. The data model and the first cross-repo adapter are still being shaped
|
|
48
|
+
> (see `docs/issues/`). Not yet on PyPI.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv sync # dependencies
|
|
54
|
+
make install-hooks
|
|
55
|
+
make check # lint + typecheck + test
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Why not just Doorstop / StrictDoc?
|
|
59
|
+
|
|
60
|
+
Those track "the system shall…" requirements for auditors in regulated industries. prodc
|
|
61
|
+
tracks personas, flows and features for product decisions, and derives live status from the
|
|
62
|
+
project's own tests. It reads a repo it is pointed at — including existing Gherkin/BDD and
|
|
63
|
+
whatever ID scheme already lives there — rather than asking you to re-author everything in a
|
|
64
|
+
new grammar.
|
|
65
|
+
|
|
66
|
+
## Layout
|
|
67
|
+
|
|
68
|
+
- `src/prodc/` — the package (typed; `py.typed`).
|
|
69
|
+
- `docs/issues/` — follow-ups (the `cabildo issues` convention).
|
|
70
|
+
- `Makefile` — `format` / `lint` / `typecheck` / `test` / `check` / `build` / `docs`.
|
|
71
|
+
- `AGENTS.md` — build/test/style/gotchas for coding agents.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
prodc/__init__.py,sha256=FrOr-0V0IGRPK-ygMrR6J8D2xni-ML48IhdUyOCXVRc,64
|
|
2
|
+
prodc/adapters.py,sha256=vBn-nKjoAQQhCXoOpd1q-vpQyr-p0xwZkPDiLA4G9Nc,7607
|
|
3
|
+
prodc/cli.py,sha256=gKXD-w_43lrLdhazL_mHm0W2NZjtxjVN7sKaF-N1MWA,7774
|
|
4
|
+
prodc/config.py,sha256=v2YB10QOrjfppW2vBpx_ISuyXf9myC_02-jk0hRtGlw,2801
|
|
5
|
+
prodc/loader.py,sha256=UoVh0eYU_vVneDMvLAmxBkpKmwO7PvNVrBpH-nDy9rw,4669
|
|
6
|
+
prodc/model.py,sha256=F7n54ldZ4trWsJDpMU2NfjGEijfGNJkamrTkFoHBexY,3884
|
|
7
|
+
prodc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
prodc/status.py,sha256=-7jwaq6qnGc1IijplPHr2tN5tQYqyoItpdCy3RR15pI,5436
|
|
9
|
+
prodc-0.2.0.dist-info/METADATA,sha256=0XsxmdvXnSI0IhZImC4sI1Tx1-ASRXMvzbsm166J7TQ,3152
|
|
10
|
+
prodc-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
prodc-0.2.0.dist-info/entry_points.txt,sha256=6oG__PQREzQvf9meznTF18lRMg5smZhl1x_yzovU2e4,41
|
|
12
|
+
prodc-0.2.0.dist-info/licenses/LICENSE,sha256=6dP7w8m4xJH1IQumkEnTTCY4BJO16jnKEG-xPIj9y90,1070
|
|
13
|
+
prodc-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jorge Cardona
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|