prodc 0.4.2__tar.gz → 0.4.3__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.4.2 → prodc-0.4.3}/PKG-INFO +1 -1
- {prodc-0.4.2 → prodc-0.4.3}/pyproject.toml +1 -1
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/render.py +13 -2
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/serialize.py +4 -1
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/loader.py +2 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/model.py +9 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/report.py +5 -4
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/report_html.py +11 -2
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/status.py +20 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_loader.py +20 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_status.py +17 -1
- {prodc-0.4.2 → prodc-0.4.3}/.gitignore +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/CHANGELOG.md +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/LICENSE +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/README.md +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/__init__.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/adapters.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/agent.md +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/board.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/app.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/commands.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/config.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/dedup.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/guide.md +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/py.typed +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/src/prodc/writer.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_adapters.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_answer.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_callbacks.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_citation.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_cli.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_command_hook.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_contradicts.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_decisions.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_dedup.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_description.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_drafts.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_evalgate.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_importance.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_models.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_needref.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_principles.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_relink.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_report.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_report_html.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_review.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_roles.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_source_role.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_sources.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_staleness.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_suggest.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_variants.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_who_variants.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_withdrawn.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_writer.py +0 -0
- {prodc-0.4.2 → prodc-0.4.3}/tests/test_writer_comments.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Product management as code: roles, 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
|
|
@@ -7,6 +7,7 @@ formatting: it reads the resolved model and returns strings, never loads or writ
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import datetime
|
|
10
11
|
import shutil
|
|
11
12
|
from collections.abc import Callable
|
|
12
13
|
|
|
@@ -27,6 +28,7 @@ from ..status import (
|
|
|
27
28
|
PrincipleReport,
|
|
28
29
|
Recurrence,
|
|
29
30
|
Summary,
|
|
31
|
+
group_state,
|
|
30
32
|
orphaned_decisions,
|
|
31
33
|
rank,
|
|
32
34
|
state_priority,
|
|
@@ -209,12 +211,21 @@ def render_out_of_scope(results: list[NeedResult]) -> str:
|
|
|
209
211
|
|
|
210
212
|
|
|
211
213
|
def render_goals(goal_cov: list[tuple[Goal, Summary]], ungoaled: list[NeedResult]) -> str:
|
|
212
|
-
"""Per-goal coverage, plus the needs that serve no objective (#27)."""
|
|
214
|
+
"""Per-goal coverage with a derived state and target, plus the needs that serve no objective (#27, #80)."""
|
|
215
|
+
today = datetime.date.today().isoformat()
|
|
213
216
|
out = ["\ngoals (objectives needs trace up to):"]
|
|
214
217
|
for goal, gsum in goal_cov:
|
|
218
|
+
state = group_state(gsum)
|
|
215
219
|
metric = f" [{goal.metric}]" if goal.metric else ""
|
|
216
220
|
pct = round(gsum.coverage * 100)
|
|
217
|
-
|
|
221
|
+
overdue = goal.target is not None and goal.target < today and state is not NeedState.DELIVERED
|
|
222
|
+
target = ""
|
|
223
|
+
if goal.target:
|
|
224
|
+
target = f" ⚠ overdue {goal.target}" if overdue else f" target {goal.target}"
|
|
225
|
+
out.append(
|
|
226
|
+
f" {goal.id:<12} {_LABEL[state]:<10} {gsum.delivered}/{gsum.total} ({pct}%)"
|
|
227
|
+
f"{metric}{target} {goal.text}"
|
|
228
|
+
)
|
|
218
229
|
if ungoaled:
|
|
219
230
|
ids = ", ".join(r.need.id for r in ungoaled)
|
|
220
231
|
out.append(f" ⚠ {len(ungoaled)} need(s) serve no goal: {ids}")
|
|
@@ -13,7 +13,7 @@ from ..board import need_payload
|
|
|
13
13
|
from ..board import warrant as _warrant
|
|
14
14
|
from ..dedup import DupPair, VariantGroup
|
|
15
15
|
from ..model import Decision, Goal, NeedResult, NeedState, Release, Role
|
|
16
|
-
from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
|
|
16
|
+
from ..status import Handoff, PrincipleReport, Recurrence, Summary, group_state, rank
|
|
17
17
|
from .common import brief_open
|
|
18
18
|
|
|
19
19
|
|
|
@@ -119,6 +119,9 @@ def to_json(
|
|
|
119
119
|
"id": goal.id,
|
|
120
120
|
"text": goal.text,
|
|
121
121
|
"metric": goal.metric,
|
|
122
|
+
"description": goal.description,
|
|
123
|
+
"target": goal.target,
|
|
124
|
+
"state": group_state(gsum).value,
|
|
122
125
|
"coverage": round(gsum.coverage, 4),
|
|
123
126
|
"delivered": gsum.delivered,
|
|
124
127
|
"total": gsum.total,
|
|
@@ -322,6 +322,8 @@ def _goal(raw: object, where: str) -> Goal:
|
|
|
322
322
|
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
323
323
|
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
324
324
|
metric=_opt_str(tbl.get("metric"), f"{where}.metric"),
|
|
325
|
+
description=_opt_str(tbl.get("description"), f"{where}.description"),
|
|
326
|
+
target=_opt_str(tbl.get("target"), f"{where}.target"),
|
|
325
327
|
)
|
|
326
328
|
|
|
327
329
|
|
|
@@ -102,11 +102,20 @@ class Goal:
|
|
|
102
102
|
|
|
103
103
|
A need with no ``goal`` is flagged the way an unsourced need is flagged an opinion — it
|
|
104
104
|
lets the board answer "are we moving the objective", not only "is this need delivered".
|
|
105
|
+
|
|
106
|
+
A goal is also the home for the long-lived container other trackers call an *initiative* or
|
|
107
|
+
*epic* (#80): a ``description`` and an optional ``target`` window give it a title, a blurb and a
|
|
108
|
+
deadline, and its state is *derived* from the needs that cite it (:func:`~prodc.status.group_state`),
|
|
109
|
+
never hand-set — so an "is this big piece of work done" rolls up from the proofs, not a status
|
|
110
|
+
field that drifts. No separate ``initiative`` noun: an output bundle that is neither an outcome
|
|
111
|
+
(goal) nor a ship slice (release) is engineering tracking, not product.
|
|
105
112
|
"""
|
|
106
113
|
|
|
107
114
|
id: str
|
|
108
115
|
text: str
|
|
109
116
|
metric: str | None = None # how the objective is measured, e.g. "churn < 5%"
|
|
117
|
+
description: str | None = None # optional longer blurb — the initiative's prose (#80)
|
|
118
|
+
target: str | None = None # optional ISO date the objective is aimed at; past + not DELIVERED warns
|
|
110
119
|
|
|
111
120
|
|
|
112
121
|
@dataclass(frozen=True)
|
|
@@ -14,7 +14,7 @@ from datetime import date
|
|
|
14
14
|
from .board import gather
|
|
15
15
|
from .config import Config
|
|
16
16
|
from .model import Model, NeedResult, NeedState
|
|
17
|
-
from .status import active_decisions, principle_report, state_priority
|
|
17
|
+
from .status import active_decisions, group_state, principle_report, state_priority
|
|
18
18
|
|
|
19
19
|
_LABEL = {
|
|
20
20
|
NeedState.DELIVERED: "DELIVERED",
|
|
@@ -97,11 +97,12 @@ def render_markdown(
|
|
|
97
97
|
out.append("")
|
|
98
98
|
|
|
99
99
|
if model.goals:
|
|
100
|
-
out += ["## Goals", "", "| goal | delivered | goal |", "
|
|
100
|
+
out += ["## Goals", "", "| goal | state | delivered | target | goal |", "|---|---|---|---|---|"]
|
|
101
101
|
for goal, gsum in goal_cov:
|
|
102
102
|
out.append(
|
|
103
|
-
f"| {goal.id} | {
|
|
104
|
-
f"| {
|
|
103
|
+
f"| {goal.id} | {_LABEL[group_state(gsum)]} "
|
|
104
|
+
f"| {gsum.delivered}/{gsum.total} ({round(gsum.coverage * 100)}%) "
|
|
105
|
+
f"| {goal.target or ''} | {goal.text} |"
|
|
105
106
|
)
|
|
106
107
|
if ungoaled:
|
|
107
108
|
out.append("")
|
|
@@ -15,7 +15,7 @@ from datetime import date
|
|
|
15
15
|
from .board import gather, need_payload
|
|
16
16
|
from .config import Config
|
|
17
17
|
from .model import Model
|
|
18
|
-
from .status import active_decisions, principle_report
|
|
18
|
+
from .status import active_decisions, group_state, principle_report
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def board_payload(
|
|
@@ -55,7 +55,16 @@ def board_payload(
|
|
|
55
55
|
for r in results
|
|
56
56
|
if r.need.product and not r.need.is_withdrawn
|
|
57
57
|
],
|
|
58
|
-
"goals": [
|
|
58
|
+
"goals": [
|
|
59
|
+
{
|
|
60
|
+
"id": g.id,
|
|
61
|
+
"text": g.text,
|
|
62
|
+
"target": g.target,
|
|
63
|
+
"state": group_state(s).value,
|
|
64
|
+
**_cov(s.delivered, s.total),
|
|
65
|
+
}
|
|
66
|
+
for g, s in goal_cov
|
|
67
|
+
],
|
|
59
68
|
"ungoaled": [r.need.id for r in ungoaled],
|
|
60
69
|
"releases": [
|
|
61
70
|
{"id": rel.id, "text": rel.text, **_cov(s.delivered, s.total)} for rel, s in rel_cov
|
|
@@ -385,6 +385,26 @@ def summarize(results: list[NeedResult]) -> Summary:
|
|
|
385
385
|
)
|
|
386
386
|
|
|
387
387
|
|
|
388
|
+
def group_state(s: Summary) -> NeedState:
|
|
389
|
+
"""The derived state of an FK group (goal, release, role) — the roll-up of its members (#80).
|
|
390
|
+
|
|
391
|
+
One function behind every group's state, so a goal/initiative, a release slice and a role all
|
|
392
|
+
read the same way and none carries a hand-set status that can drift from its needs: BROKEN if
|
|
393
|
+
any member proof is broken, UNRESOLVED if any source couldn't be read, DELIVERED only when every
|
|
394
|
+
member is, HOLE when nothing is covered yet, else PENDING (in progress). An empty group is a
|
|
395
|
+
HOLE (nothing delivered, nothing pending).
|
|
396
|
+
"""
|
|
397
|
+
if s.broken:
|
|
398
|
+
return NeedState.BROKEN
|
|
399
|
+
if s.unresolved:
|
|
400
|
+
return NeedState.UNRESOLVED
|
|
401
|
+
if s.total and s.delivered == s.total:
|
|
402
|
+
return NeedState.DELIVERED
|
|
403
|
+
if s.delivered == 0 and s.pending == 0:
|
|
404
|
+
return NeedState.HOLE
|
|
405
|
+
return NeedState.PENDING
|
|
406
|
+
|
|
407
|
+
|
|
388
408
|
@dataclass(frozen=True)
|
|
389
409
|
class Recurrence:
|
|
390
410
|
"""A baseline need and the clients that carry a variant of it (#32)."""
|
|
@@ -262,6 +262,26 @@ def test_goals_loaded_and_need_traces_up(tmp_path: Path) -> None:
|
|
|
262
262
|
assert model.needs[0].goal == "G-1"
|
|
263
263
|
|
|
264
264
|
|
|
265
|
+
def test_goal_loads_description_and_target(tmp_path: Path) -> None:
|
|
266
|
+
# #80: a goal is the home for the initiative — a description and an optional target window.
|
|
267
|
+
from prodc.loader import load
|
|
268
|
+
|
|
269
|
+
cfg = _config(
|
|
270
|
+
tmp_path,
|
|
271
|
+
(
|
|
272
|
+
"goals.yaml",
|
|
273
|
+
"goals:\n"
|
|
274
|
+
" - id: INIT-SEARCH\n"
|
|
275
|
+
" text: Second-generation search\n"
|
|
276
|
+
" description: the long blurb\n"
|
|
277
|
+
" target: 2026-12-15\n",
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
goal = load(cfg).goals[0]
|
|
281
|
+
assert goal.description == "the long blurb"
|
|
282
|
+
assert goal.target == "2026-12-15"
|
|
283
|
+
|
|
284
|
+
|
|
265
285
|
def test_need_goal_unknown_raises(tmp_path: Path) -> None:
|
|
266
286
|
cfg = _config(tmp_path, ("a.yaml", "needs:\n - id: N-1\n text: t\n goal: NOPE\n"))
|
|
267
287
|
with pytest.raises(LoaderError, match="goal 'NOPE' is not a known goal id"):
|
|
@@ -7,7 +7,23 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
|
|
9
9
|
from prodc.model import Child, Need, NeedState, ProofState, Source, SourceKind
|
|
10
|
-
from prodc.status import resolve
|
|
10
|
+
from prodc.status import Summary, group_state, resolve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _summary(**kw: int) -> Summary:
|
|
14
|
+
base = dict(total=0, delivered=0, pending=0, hole=0, must_hole=0, broken=0, unresolved=0, opinion=0)
|
|
15
|
+
base.update(kw)
|
|
16
|
+
return Summary(**base)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_group_state_rolls_up_members() -> None:
|
|
20
|
+
# #80: a goal/release/role state is derived from its members, never hand-set.
|
|
21
|
+
assert group_state(_summary()) is NeedState.HOLE # empty group
|
|
22
|
+
assert group_state(_summary(total=2, hole=2)) is NeedState.HOLE # nothing started
|
|
23
|
+
assert group_state(_summary(total=2, delivered=2)) is NeedState.DELIVERED
|
|
24
|
+
assert group_state(_summary(total=2, delivered=1, pending=1)) is NeedState.PENDING
|
|
25
|
+
assert group_state(_summary(total=2, delivered=1, broken=1)) is NeedState.BROKEN # broken wins
|
|
26
|
+
assert group_state(_summary(total=2, delivered=1, unresolved=1)) is NeedState.UNRESOLVED
|
|
11
27
|
|
|
12
28
|
|
|
13
29
|
@dataclass(frozen=True)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|