prodc 0.3.6__tar.gz → 0.3.7__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.6
3
+ Version: 0.3.7
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.6"
3
+ version = "0.3.7"
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"
@@ -32,6 +32,21 @@ def _steps_passed(steps: list[object]) -> bool:
32
32
  return True
33
33
 
34
34
 
35
+ def _git_head(repo: Path) -> str | None:
36
+ """Short git rev of a checked-out repo, read from .git/HEAD (no git binary needed)."""
37
+ head = repo / ".git" / "HEAD"
38
+ try:
39
+ ref = head.read_text(encoding="utf-8").strip()
40
+ except OSError:
41
+ return None
42
+ if ref.startswith("ref:"):
43
+ try:
44
+ return (repo / ".git" / ref[4:].strip()).read_text(encoding="utf-8").strip()[:7]
45
+ except OSError:
46
+ return None
47
+ return ref[:7]
48
+
49
+
35
50
  @dataclass(frozen=True)
36
51
  class ProofResult:
37
52
  state: ProofState
@@ -259,6 +274,30 @@ class AcceptAdapter:
259
274
  return ProofResult(ProofState.DELIVERED, True, detail)
260
275
 
261
276
 
277
+ class External:
278
+ """Wraps a proof adapter that resolves against a sibling repo checked out at ``repo``.
279
+
280
+ If the sibling is absent the fact could not be read at all → UNRESOLVED, distinct
281
+ from a BROKEN locator (read, resolves to nothing) and not a coverage miss. On a read,
282
+ the sibling's git HEAD is appended to the detail so the result is traceable to the
283
+ exact upstream revision (reproducible across machines, not just per-checkout).
284
+ """
285
+
286
+ def __init__(self, inner: Adapter, repo: Path) -> None:
287
+ self._inner = inner
288
+ self._repo = repo
289
+
290
+ def resolve(self, payload: str) -> ProofResult:
291
+ if not self._repo.exists():
292
+ return ProofResult(
293
+ ProofState.UNRESOLVED, False, f"sibling '{self._repo.name}' not checked out"
294
+ )
295
+ res = self._inner.resolve(payload)
296
+ rev = _git_head(self._repo)
297
+ suffix = f" @ {self._repo.name}:{rev}" if rev else f" @ {self._repo.name}"
298
+ return ProofResult(res.state, res.declared, res.detail + suffix)
299
+
300
+
262
301
  @dataclass(frozen=True)
263
302
  class SourceResult:
264
303
  """Whether a Source.locator resolves. Verifies provenance *exists*, not that it is good."""
@@ -25,6 +25,7 @@ _LABEL = {
25
25
  NeedState.PENDING: "PENDING",
26
26
  NeedState.HOLE: "HOLE",
27
27
  NeedState.BROKEN: "BROKEN",
28
+ NeedState.UNRESOLVED: "UNRESOLVED",
28
29
  }
29
30
 
30
31
 
@@ -34,8 +35,9 @@ def _priority(r: NeedResult) -> int:
34
35
  return {
35
36
  NeedState.BROKEN: 1,
36
37
  NeedState.PENDING: 2,
37
- NeedState.HOLE: 3,
38
- NeedState.DELIVERED: 4,
38
+ NeedState.UNRESOLVED: 3,
39
+ NeedState.HOLE: 4,
40
+ NeedState.DELIVERED: 5,
39
41
  }[r.state]
40
42
 
41
43
 
@@ -87,6 +89,8 @@ def _summary_line(s: Summary) -> str:
87
89
  parts.append(f"{s.must_hole} must-HOLE")
88
90
  if s.broken:
89
91
  parts.append(f"{s.broken} BROKEN")
92
+ if s.unresolved:
93
+ parts.append(f"{s.unresolved} UNRESOLVED")
90
94
  if s.pending:
91
95
  parts.append(f"{s.pending} PENDING")
92
96
  if s.hole - s.must_hole:
@@ -154,6 +158,7 @@ def _to_json(results: list[NeedResult], summary: Summary, orphan_map: dict[str,
154
158
  "hole": summary.hole,
155
159
  "must_hole": summary.must_hole,
156
160
  "broken": summary.broken,
161
+ "unresolved": summary.unresolved,
157
162
  "opinion": summary.opinion,
158
163
  "coverage": round(summary.coverage, 4),
159
164
  "orphans": sum(len(v) for v in orphan_map.values()),
@@ -10,6 +10,7 @@ from typing import cast
10
10
  from .adapters import (
11
11
  AcceptAdapter,
12
12
  Adapter,
13
+ External,
13
14
  FilesAdapter,
14
15
  FrontmatterAdapter,
15
16
  GherkinAdapter,
@@ -61,9 +62,13 @@ class Config:
61
62
  kind = tbl.get("kind")
62
63
  results = tbl.get("results")
63
64
  results_s = results if isinstance(results, str) else None
65
+ # `repo` points the adapter at a sibling repo checked out beside this one;
66
+ # its paths then resolve under that repo and reads are wrapped by External.
67
+ repo_field = tbl.get("repo")
68
+ proof_root = root / repo_field if isinstance(repo_field, str) else root
64
69
  if kind == "gherkin":
65
- adapters[name] = GherkinAdapter(
66
- root=root,
70
+ inner: Adapter = GherkinAdapter(
71
+ root=proof_root,
67
72
  paths=_as_str_list(tbl.get("paths", []), f"[proofs.{name}].paths"),
68
73
  delivered_tags=_as_str_list(
69
74
  tbl.get("delivered", []), f"[proofs.{name}].delivered"
@@ -75,19 +80,22 @@ class Config:
75
80
  )
76
81
  elif kind == "pytest":
77
82
  sub = tbl.get("root", ".")
78
- adapters[name] = PytestAdapter(
79
- root=root / (sub if isinstance(sub, str) else "."),
83
+ inner = PytestAdapter(
84
+ root=proof_root / (sub if isinstance(sub, str) else "."),
80
85
  results=results_s,
81
86
  )
82
87
  elif kind == "files":
83
88
  sub = tbl.get("root", ".")
84
- adapters[name] = FilesAdapter(root=root / (sub if isinstance(sub, str) else "."))
89
+ inner = FilesAdapter(root=proof_root / (sub if isinstance(sub, str) else "."))
85
90
  elif kind == "accept":
86
- adapters[name] = AcceptAdapter()
91
+ inner = AcceptAdapter()
87
92
  else:
88
93
  raise ConfigError(
89
94
  f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest|files|accept)"
90
95
  )
96
+ adapters[name] = (
97
+ External(inner, root / repo_field) if isinstance(repo_field, str) else inner
98
+ )
91
99
 
92
100
  source_adapters: dict[str, SourceAdapter] = {}
93
101
  sources = _as_dict(raw.get("sources", {}), "[sources]")
@@ -27,6 +27,7 @@ class ProofState(StrEnum):
27
27
  DELIVERED = "delivered" # the test exists and (if a results artifact is present) passes
28
28
  PENDING = "pending" # declared/planned, not yet green
29
29
  BROKEN = "broken" # the locator does not resolve to anything
30
+ UNRESOLVED = "unresolved" # the source could not be read at all (e.g. sibling repo absent)
30
31
 
31
32
 
32
33
  class NeedState(StrEnum):
@@ -36,6 +37,7 @@ class NeedState(StrEnum):
36
37
  PENDING = "pending"
37
38
  HOLE = "hole" # no proof at all — an honest gap, never a build failure
38
39
  BROKEN = "broken" # a proof locator points at nothing
40
+ UNRESOLVED = "unresolved" # a proof's source could not be read (e.g. sibling repo absent)
39
41
 
40
42
 
41
43
  @dataclass(frozen=True)
@@ -104,6 +104,8 @@ class Resolver:
104
104
  declared = any(r.declared for r in results if r.state is ProofState.DELIVERED)
105
105
  if any(r.state is ProofState.BROKEN for r in results):
106
106
  state = NeedState.BROKEN
107
+ elif any(r.state is ProofState.UNRESOLVED for r in results):
108
+ state = NeedState.UNRESOLVED # a source couldn't be read — can't judge yet
107
109
  elif delivered == len(results):
108
110
  state = NeedState.DELIVERED
109
111
  else:
@@ -125,6 +127,8 @@ class Resolver:
125
127
  total = len(child_states)
126
128
  if any(s is NeedState.BROKEN for s in child_states):
127
129
  state = NeedState.BROKEN
130
+ elif any(s is NeedState.UNRESOLVED for s in child_states):
131
+ state = NeedState.UNRESOLVED
128
132
  elif total and delivered == total:
129
133
  state = NeedState.DELIVERED
130
134
  elif not child_states or all(s is NeedState.HOLE for s in child_states):
@@ -154,12 +158,19 @@ class Summary:
154
158
  hole: int
155
159
  must_hole: int # a HOLE on a must-have — the top of the board
156
160
  broken: int # a dangling proof locator
161
+ unresolved: int # a proof whose source could not be read (e.g. sibling repo absent)
157
162
  opinion: int # a need with no source of any kind
158
163
 
159
164
  @property
160
165
  def coverage(self) -> float:
161
- """Fraction of needs that are DELIVERED (0.0 when there are no needs)."""
162
- return self.delivered / self.total if self.total else 0.0
166
+ """Fraction DELIVERED, of needs that could be read.
167
+
168
+ UNRESOLVED needs (a source that couldn't be read) are excluded from the
169
+ denominator — they neither help nor hurt coverage, so a `--min-coverage` gate
170
+ isn't failed by an absent sibling the way a BROKEN locator fails it.
171
+ """
172
+ judged = self.total - self.unresolved
173
+ return self.delivered / judged if judged else 0.0
163
174
 
164
175
 
165
176
  def summarize(results: list[NeedResult]) -> Summary:
@@ -172,6 +183,7 @@ def summarize(results: list[NeedResult]) -> Summary:
172
183
  hole=by_state[NeedState.HOLE],
173
184
  must_hole=sum(1 for r in results if r.need.must and r.state is NeedState.HOLE),
174
185
  broken=by_state[NeedState.BROKEN],
186
+ unresolved=by_state[NeedState.UNRESOLVED],
175
187
  opinion=sum(1 for r in results if r.need.is_opinion),
176
188
  )
177
189
 
@@ -4,7 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- from prodc.adapters import AcceptAdapter, FilesAdapter, FrontmatterAdapter, PytestAdapter
7
+ from prodc.adapters import (
8
+ AcceptAdapter,
9
+ External,
10
+ FilesAdapter,
11
+ FrontmatterAdapter,
12
+ PytestAdapter,
13
+ )
8
14
  from prodc.model import ProofState
9
15
 
10
16
 
@@ -78,3 +84,20 @@ def test_accept_adapter_is_declared_delivered() -> None:
78
84
  r = AcceptAdapter().resolve("jane@2026-09-20")
79
85
  assert r.state is ProofState.DELIVERED and r.declared # a human sign-off, not a test
80
86
  assert "accepted by jane" in r.detail and "2026-09-20" in r.detail
87
+
88
+
89
+ def test_external_unresolved_when_sibling_absent(tmp_path: Path) -> None:
90
+ ext = External(FilesAdapter(root=tmp_path / "sib"), tmp_path / "sib") # not checked out
91
+ r = ext.resolve("verify.py")
92
+ assert r.state is ProofState.UNRESOLVED and not r.declared
93
+
94
+
95
+ def test_external_reads_sibling_and_records_rev(tmp_path: Path) -> None:
96
+ sib = tmp_path / "sib"
97
+ sib.mkdir()
98
+ (sib / "verify.py").write_text("# model\n", encoding="utf-8")
99
+ (sib / ".git").mkdir()
100
+ (sib / ".git" / "HEAD").write_text("abc1234def5678\n", encoding="utf-8") # detached HEAD
101
+ r = External(FilesAdapter(root=sib), sib).resolve("verify.py")
102
+ assert r.state is ProofState.DELIVERED
103
+ assert "@ sib:abc1234" in r.detail # traceable to the sibling's exact revision
@@ -65,6 +65,21 @@ def test_solution_verb_warns() -> None:
65
65
  assert any("solution in disguise" in w for w in r.warnings)
66
66
 
67
67
 
68
+ def test_unresolved_proof_is_excluded_from_coverage() -> None:
69
+ from prodc.status import summarize
70
+
71
+ delivered = StubAdapter(ProofResult(ProofState.DELIVERED, declared=True, detail="ok"))
72
+ unresolved = StubAdapter(ProofResult(ProofState.UNRESOLVED, declared=False, detail="absent"))
73
+ need_ok = Need(id="D", text="t", proofs=("web:x",))
74
+ need_un = Need(id="U", text="t", proofs=("ext:x",))
75
+ results = resolve([need_ok, need_un], {"web": delivered, "ext": unresolved})
76
+ by_id = {r.need.id: r for r in results}
77
+ assert by_id["U"].state is NeedState.UNRESOLVED
78
+ s = summarize(results)
79
+ assert s.unresolved == 1 and s.delivered == 1
80
+ assert s.coverage == 1.0 # 1 delivered / (2 total - 1 unresolved); the absent one is excluded
81
+
82
+
68
83
  def test_dangling_source_warns_distinct_from_opinion(tmp_path: Path) -> None:
69
84
  (tmp_path / "issues").mkdir()
70
85
  (tmp_path / "issues" / "reg.md").write_text("# A-1 ok\n", encoding="utf-8")
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