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.
- {prodc-0.3.6 → prodc-0.3.7}/PKG-INFO +1 -1
- {prodc-0.3.6 → prodc-0.3.7}/pyproject.toml +1 -1
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/adapters.py +39 -0
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/cli.py +7 -2
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/config.py +14 -6
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/model.py +2 -0
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/status.py +14 -2
- {prodc-0.3.6 → prodc-0.3.7}/tests/test_adapters.py +24 -1
- {prodc-0.3.6 → prodc-0.3.7}/tests/test_status.py +15 -0
- {prodc-0.3.6 → prodc-0.3.7}/.gitignore +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/CHANGELOG.md +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/LICENSE +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/README.md +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/__init__.py +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/loader.py +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/src/prodc/py.typed +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/tests/test_cli.py +0 -0
- {prodc-0.3.6 → prodc-0.3.7}/tests/test_loader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -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.
|
|
38
|
-
NeedState.
|
|
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
|
-
|
|
66
|
-
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
|
-
|
|
79
|
-
root=
|
|
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
|
-
|
|
89
|
+
inner = FilesAdapter(root=proof_root / (sub if isinstance(sub, str) else "."))
|
|
85
90
|
elif kind == "accept":
|
|
86
|
-
|
|
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
|
|
162
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|