prodc 0.3.7__tar.gz → 0.3.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.7
3
+ Version: 0.3.9
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
@@ -44,8 +44,34 @@ The lineage is Toulmin's argument model (claim + grounds + warrant): a feature i
44
44
  its evidence is the grounds, its assumptions are the warrant. The same shape safety
45
45
  engineering uses for a "safety case", pointed at product decisions.
46
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.
47
+ ## What it's for
48
+
49
+ prodc does not replace the human conversation with a client — it is scaffolding around it.
50
+ Going in, the board already shows what is still unknown for that client: needs with no
51
+ evidence (opinions) and needs with no proof (HOLEs), so the meeting is spent closing real
52
+ gaps instead of re-deriving them. Coming out, what was learned is captured as evidence on
53
+ the need, so the next decision is planned from the repo rather than from memory. The board
54
+ answers "where are we" and "what's blocking" between meetings, without a standup.
55
+
56
+ ## Prior art
57
+
58
+ prodc draws on, and tries to credit, several existing lines of work:
59
+
60
+ - **Continuous discovery / opportunity framing** — Teresa Torres (opportunity = need; the
61
+ interview > artifact > internal evidence ladder; "solutions in disguise").
62
+ - **The argument/assurance model** — Toulmin (claim · grounds · warrant) and the safety-case
63
+ / GSN tradition it informs.
64
+ - **Spec-driven development** — product/requirements modelled as plain JSON/YAML in git
65
+ (the ThoughtWorks / Fowler SDD writing).
66
+ - **Structured requirements** — EARS (Easy Approach to Requirements Syntax), the NASA
67
+ requirement-writing guidance, and the SysML lineage.
68
+ - **Requirements-as-code tooling** — Doorstop and StrictDoc (plain-text, git-tracked, linked
69
+ IDs), which prodc follows in form but points at product artifacts rather than "shall"
70
+ statements.
71
+
72
+ > Status: v0.3, on PyPI (`pip install prodc`). The model (Need · Source · Proof), the proof
73
+ > and source adapters, and the status / show / diff / portfolio commands are in; see
74
+ > `docs/issues/` for what's still open.
49
75
 
50
76
  ## Install
51
77
 
@@ -18,8 +18,34 @@ The lineage is Toulmin's argument model (claim + grounds + warrant): a feature i
18
18
  its evidence is the grounds, its assumptions are the warrant. The same shape safety
19
19
  engineering uses for a "safety case", pointed at product decisions.
20
20
 
21
- > Status: v0, early. The data model and the first cross-repo adapter are still being shaped
22
- > (see `docs/issues/`). Not yet on PyPI.
21
+ ## What it's for
22
+
23
+ prodc does not replace the human conversation with a client — it is scaffolding around it.
24
+ Going in, the board already shows what is still unknown for that client: needs with no
25
+ evidence (opinions) and needs with no proof (HOLEs), so the meeting is spent closing real
26
+ gaps instead of re-deriving them. Coming out, what was learned is captured as evidence on
27
+ the need, so the next decision is planned from the repo rather than from memory. The board
28
+ answers "where are we" and "what's blocking" between meetings, without a standup.
29
+
30
+ ## Prior art
31
+
32
+ prodc draws on, and tries to credit, several existing lines of work:
33
+
34
+ - **Continuous discovery / opportunity framing** — Teresa Torres (opportunity = need; the
35
+ interview > artifact > internal evidence ladder; "solutions in disguise").
36
+ - **The argument/assurance model** — Toulmin (claim · grounds · warrant) and the safety-case
37
+ / GSN tradition it informs.
38
+ - **Spec-driven development** — product/requirements modelled as plain JSON/YAML in git
39
+ (the ThoughtWorks / Fowler SDD writing).
40
+ - **Structured requirements** — EARS (Easy Approach to Requirements Syntax), the NASA
41
+ requirement-writing guidance, and the SysML lineage.
42
+ - **Requirements-as-code tooling** — Doorstop and StrictDoc (plain-text, git-tracked, linked
43
+ IDs), which prodc follows in form but points at product artifacts rather than "shall"
44
+ statements.
45
+
46
+ > Status: v0.3, on PyPI (`pip install prodc`). The model (Need · Source · Proof), the proof
47
+ > and source adapters, and the status / show / diff / portfolio commands are in; see
48
+ > `docs/issues/` for what's still open.
23
49
 
24
50
  ## Install
25
51
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.7"
3
+ version = "0.3.9"
4
4
  description = "Product management as code: personas, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -274,6 +274,89 @@ class AcceptAdapter:
274
274
  return ProofResult(ProofState.DELIVERED, True, detail)
275
275
 
276
276
 
277
+ class CoverageAdapter:
278
+ """Resolves a category path against a catalog of existing categories (one per line).
279
+
280
+ DELIVERED when a category matches (already covered — point them at it); PENDING when
281
+ nothing matches (declared, not yet covered — an honest gap, not a build failure).
282
+ """
283
+
284
+ def __init__(self, root: Path, catalog: str) -> None:
285
+ self._path = root / catalog
286
+ self._cats: set[str] | None = None
287
+
288
+ def _load(self) -> set[str]:
289
+ if self._cats is None:
290
+ out: set[str] = set()
291
+ if self._path.exists():
292
+ for line in self._path.read_text(encoding="utf-8").splitlines():
293
+ s = line.strip()
294
+ if s and not s.startswith("#"):
295
+ out.add(s)
296
+ self._cats = out
297
+ return self._cats
298
+
299
+ def resolve(self, payload: str) -> ProofResult:
300
+ if payload in self._load():
301
+ return ProofResult(ProofState.DELIVERED, True, f"covered by '{payload}'")
302
+ return ProofResult(ProofState.PENDING, True, f"no category matches '{payload}'")
303
+
304
+
305
+ class FieldAdapter:
306
+ """Resolves a field name against an upstream schema/sample JSON.
307
+
308
+ Schema: ``{"surfaced": [...], "received": [...]}``. DELIVERED if the field is surfaced;
309
+ PENDING otherwise, with a distinct detail for 'received but not surfaced' vs 'not
310
+ received upstream' — a different fix. Never a hard failure.
311
+ """
312
+
313
+ def __init__(self, root: Path, schema: str) -> None:
314
+ self._path = root / schema
315
+ self._data: tuple[set[str], set[str]] | None = None
316
+
317
+ def _load(self) -> tuple[set[str], set[str]]:
318
+ if self._data is None:
319
+ surfaced: set[str] = set()
320
+ received: set[str] = set()
321
+ if self._path.exists():
322
+ raw: object = json.loads(self._path.read_text(encoding="utf-8"))
323
+ if isinstance(raw, dict):
324
+ d = cast("dict[str, object]", raw)
325
+ s, r = d.get("surfaced"), d.get("received")
326
+ if isinstance(s, list):
327
+ surfaced = {x for x in cast("list[object]", s) if isinstance(x, str)}
328
+ if isinstance(r, list):
329
+ received = {x for x in cast("list[object]", r) if isinstance(x, str)}
330
+ self._data = (surfaced, received)
331
+ return self._data
332
+
333
+ def resolve(self, payload: str) -> ProofResult:
334
+ surfaced, received = self._load()
335
+ if payload in surfaced:
336
+ return ProofResult(ProofState.DELIVERED, True, f"'{payload}' present and surfaced")
337
+ if payload in received:
338
+ return ProofResult(ProofState.PENDING, True, f"'{payload}' received but not surfaced")
339
+ return ProofResult(ProofState.PENDING, True, f"'{payload}' not received upstream")
340
+
341
+
342
+ class PrototypeAdapter:
343
+ """Resolves ``ref@who@date`` to a prototype validated with users in discovery.
344
+
345
+ For perception/experience needs with no pass/fail test: a prototype shown to users in
346
+ an interview is real evidence of the need and the chosen direction. A *declared*
347
+ DELIVERED (``*``), distinct from a shipped-feature test and from a human sign-off
348
+ (accept) — this validates the need/direction, not the implementation.
349
+ """
350
+
351
+ def resolve(self, payload: str) -> ProofResult:
352
+ ref, _, rest = payload.partition("@")
353
+ who, _, date = rest.partition("@")
354
+ detail = "prototype " + (ref or "?")
355
+ detail += f" shown to {who}" if who else ""
356
+ detail += f" on {date}" if date else ""
357
+ return ProofResult(ProofState.DELIVERED, True, detail)
358
+
359
+
277
360
  class External:
278
361
  """Wraps a proof adapter that resolves against a sibling repo checked out at ``repo``.
279
362
 
@@ -13,6 +13,7 @@ import shutil
13
13
  import sys
14
14
  from dataclasses import asdict, dataclass
15
15
  from pathlib import Path
16
+ from typing import cast
16
17
 
17
18
  from . import __version__
18
19
  from .config import Config, ConfigError
@@ -380,6 +381,51 @@ def _cmd_portfolio(args: argparse.Namespace) -> int:
380
381
  return 2 if any(r.error is not None for r in rows) else 0
381
382
 
382
383
 
384
+ def _cmd_diff(args: argparse.Namespace) -> int:
385
+ """Diff the current board against a prior `status --json` snapshot."""
386
+ loaded = _load_or_report(args)
387
+ if loaded is None:
388
+ return 2
389
+ cfg, needs = loaded
390
+ current = {r.need.id: r.state.value for r in resolve(needs, cfg.adapters, cfg.source_adapters)}
391
+
392
+ snap = Path(args.snapshot)
393
+ try:
394
+ payload: object = json.loads(snap.read_text(encoding="utf-8"))
395
+ except (OSError, ValueError) as exc:
396
+ print(f"prodc: cannot read snapshot {snap}: {exc}", file=sys.stderr)
397
+ return 2
398
+ raw_needs = cast("dict[str, object]", payload).get("needs") if isinstance(payload, dict) else None
399
+ old: dict[str, str] = {}
400
+ for n in cast("list[object]", raw_needs) if isinstance(raw_needs, list) else []:
401
+ if isinstance(n, dict):
402
+ entry = cast("dict[str, object]", n)
403
+ nid, st = entry.get("id"), entry.get("state")
404
+ if isinstance(nid, str) and isinstance(st, str):
405
+ old[nid] = st
406
+
407
+ new = {i: current[i] for i in sorted(current.keys() - old.keys())}
408
+ removed = sorted(old.keys() - current.keys())
409
+ changed = {
410
+ i: [old[i], current[i]] for i in sorted(old.keys() & current.keys()) if old[i] != current[i]
411
+ }
412
+ if args.json:
413
+ print(json.dumps({"new": new, "removed": removed, "changed": changed}, indent=2))
414
+ return 0
415
+ if not (new or removed or changed):
416
+ print("no changes since snapshot")
417
+ return 0
418
+ out: list[str] = []
419
+ for i, st in new.items():
420
+ out.append(f" new {i:<10} ({st})")
421
+ for i in removed:
422
+ out.append(f" removed {i}")
423
+ for i, (was, now) in changed.items():
424
+ out.append(f" changed {i:<10} {was} → {now}")
425
+ print("\n".join(out))
426
+ return 0
427
+
428
+
383
429
  def _git_rev(root: Path) -> str | None:
384
430
  head = root / ".git" / "HEAD"
385
431
  try:
@@ -437,6 +483,12 @@ def main(argv: list[str] | None = None) -> int:
437
483
  pf.add_argument("--json", action="store_true", help="machine-readable output")
438
484
  pf.set_defaults(func=_cmd_portfolio)
439
485
 
486
+ df = sub.add_parser("diff", help="diff the current board against a prior status --json snapshot")
487
+ df.add_argument("snapshot", help="path to a saved `prodc status --json` file")
488
+ df.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
489
+ df.add_argument("--json", action="store_true", help="machine-readable output")
490
+ df.set_defaults(func=_cmd_diff)
491
+
440
492
  args = parser.parse_args(argv)
441
493
  return int(args.func(args))
442
494
 
@@ -10,10 +10,13 @@ from typing import cast
10
10
  from .adapters import (
11
11
  AcceptAdapter,
12
12
  Adapter,
13
+ CoverageAdapter,
13
14
  External,
15
+ FieldAdapter,
14
16
  FilesAdapter,
15
17
  FrontmatterAdapter,
16
18
  GherkinAdapter,
19
+ PrototypeAdapter,
17
20
  PytestAdapter,
18
21
  SourceAdapter,
19
22
  )
@@ -89,9 +92,20 @@ class Config:
89
92
  inner = FilesAdapter(root=proof_root / (sub if isinstance(sub, str) else "."))
90
93
  elif kind == "accept":
91
94
  inner = AcceptAdapter()
95
+ elif kind == "prototype":
96
+ inner = PrototypeAdapter()
97
+ elif kind == "coverage":
98
+ cat = tbl.get("catalog", "")
99
+ inner = CoverageAdapter(
100
+ root=proof_root, catalog=cat if isinstance(cat, str) else ""
101
+ )
102
+ elif kind == "field":
103
+ sch = tbl.get("schema", "")
104
+ inner = FieldAdapter(root=proof_root, schema=sch if isinstance(sch, str) else "")
92
105
  else:
93
106
  raise ConfigError(
94
- f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest|files|accept)"
107
+ f"[proofs.{name}]: unknown kind {kind!r} "
108
+ "(gherkin|pytest|files|accept|prototype|coverage|field)"
95
109
  )
96
110
  adapters[name] = (
97
111
  External(inner, root / repo_field) if isinstance(repo_field, str) else inner
@@ -6,9 +6,12 @@ from pathlib import Path
6
6
 
7
7
  from prodc.adapters import (
8
8
  AcceptAdapter,
9
+ CoverageAdapter,
9
10
  External,
11
+ FieldAdapter,
10
12
  FilesAdapter,
11
13
  FrontmatterAdapter,
14
+ PrototypeAdapter,
12
15
  PytestAdapter,
13
16
  )
14
17
  from prodc.model import ProofState
@@ -92,6 +95,32 @@ def test_external_unresolved_when_sibling_absent(tmp_path: Path) -> None:
92
95
  assert r.state is ProofState.UNRESOLVED and not r.declared
93
96
 
94
97
 
98
+ def test_prototype_adapter_declared_delivered() -> None:
99
+ r = PrototypeAdapter().resolve("fig-12@users@2026-09-20")
100
+ assert r.state is ProofState.DELIVERED and r.declared
101
+ assert "prototype fig-12" in r.detail and "users" in r.detail
102
+
103
+
104
+ def test_coverage_adapter(tmp_path: Path) -> None:
105
+ (tmp_path / "catalog.txt").write_text("billing/invoices\n# a comment\nroasting/queue\n",
106
+ encoding="utf-8")
107
+ a = CoverageAdapter(root=tmp_path, catalog="catalog.txt")
108
+ assert a.resolve("billing/invoices").state is ProofState.DELIVERED
109
+ assert a.resolve("shipping/labels").state is ProofState.PENDING # not yet covered
110
+
111
+
112
+ def test_field_adapter(tmp_path: Path) -> None:
113
+ (tmp_path / "schema.json").write_text(
114
+ '{"surfaced": ["total"], "received": ["total", "tax_id"]}', encoding="utf-8"
115
+ )
116
+ a = FieldAdapter(root=tmp_path, schema="schema.json")
117
+ assert a.resolve("total").state is ProofState.DELIVERED
118
+ r = a.resolve("tax_id")
119
+ assert r.state is ProofState.PENDING and "received but not surfaced" in r.detail
120
+ r2 = a.resolve("missing")
121
+ assert r2.state is ProofState.PENDING and "not received upstream" in r2.detail
122
+
123
+
95
124
  def test_external_reads_sibling_and_records_rev(tmp_path: Path) -> None:
96
125
  sib = tmp_path / "sib"
97
126
  sib.mkdir()
@@ -179,6 +179,30 @@ def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixt
179
179
  assert code == 2
180
180
 
181
181
 
182
+ def test_diff_no_changes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
183
+ toml = str(_project(tmp_path))
184
+ main(["status", "--config", toml, "--json"])
185
+ snap = tmp_path / "snap.json"
186
+ snap.write_text(capsys.readouterr().out, encoding="utf-8")
187
+ main(["diff", str(snap), "--config", toml])
188
+ assert "no changes" in capsys.readouterr().out
189
+
190
+
191
+ def test_diff_reports_new_need(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
192
+ toml = str(_project(tmp_path))
193
+ main(["status", "--config", toml, "--json"])
194
+ snap = tmp_path / "snap.json"
195
+ snap.write_text(capsys.readouterr().out, encoding="utf-8")
196
+ # add a brand-new need, then diff against the snapshot
197
+ (tmp_path / "needs" / "a.yaml").write_text(
198
+ _NEEDS + " - id: X-9\n text: a newly added need\n", encoding="utf-8"
199
+ )
200
+ code = main(["diff", str(snap), "--config", toml])
201
+ out = capsys.readouterr().out
202
+ assert code == 0
203
+ assert "new" in out and "X-9" in out
204
+
205
+
182
206
  def test_proxy_source_kind_warrant(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
183
207
  import json
184
208
 
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