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 ADDED
@@ -0,0 +1,3 @@
1
+ """prodc: product management as code."""
2
+
3
+ __version__ = "0.1.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ prodc = prodc.cli:main
@@ -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.