prodc 0.4.1__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.1 → prodc-0.4.3}/PKG-INFO +1 -1
- {prodc-0.4.1 → prodc-0.4.3}/pyproject.toml +1 -1
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/render.py +17 -3
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/serialize.py +5 -2
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/loader.py +22 -4
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/model.py +25 -1
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/report.py +5 -4
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/report_html.py +11 -2
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/status.py +26 -6
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/writer.py +14 -1
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_loader.py +40 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_models.py +2 -2
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_status.py +24 -8
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_writer.py +27 -0
- {prodc-0.4.1 → prodc-0.4.3}/.gitignore +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/CHANGELOG.md +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/LICENSE +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/README.md +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/__init__.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/adapters.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/agent.md +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/board.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/app.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/commands.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/config.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/dedup.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/guide.md +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/src/prodc/py.typed +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_adapters.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_answer.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_callbacks.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_citation.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_cli.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_command_hook.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_contradicts.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_decisions.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_dedup.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_description.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_drafts.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_evalgate.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_importance.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_needref.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_principles.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_relink.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_report.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_report_html.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_review.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_roles.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_source_role.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_sources.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_staleness.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_suggest.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_variants.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_who_variants.py +0 -0
- {prodc-0.4.1 → prodc-0.4.3}/tests/test_withdrawn.py +0 -0
- {prodc-0.4.1 → 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}")
|
|
@@ -382,7 +393,10 @@ def render_show(
|
|
|
382
393
|
else:
|
|
383
394
|
lines.append(" sources: (none — opinion)")
|
|
384
395
|
if need.children:
|
|
385
|
-
|
|
396
|
+
rendered = ", ".join(
|
|
397
|
+
c.id if c.step is None else f"{c.id}@{c.step}" for c in need.children
|
|
398
|
+
)
|
|
399
|
+
lines.append(f" children: {rendered}")
|
|
386
400
|
if need.contradicts:
|
|
387
401
|
lines.append(f" contradicts: {', '.join(need.contradicts)}")
|
|
388
402
|
if need.seam:
|
|
@@ -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,
|
|
@@ -160,7 +163,7 @@ def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
|
|
|
160
163
|
for s in need.sources
|
|
161
164
|
],
|
|
162
165
|
"proofs": list(need.proofs),
|
|
163
|
-
"children":
|
|
166
|
+
"children": [{"id": c.id, "step": c.step} for c in need.children],
|
|
164
167
|
"seam": list(need.seam),
|
|
165
168
|
}
|
|
166
169
|
)
|
|
@@ -21,6 +21,7 @@ from .model import (
|
|
|
21
21
|
Answer,
|
|
22
22
|
Attestation,
|
|
23
23
|
AttestationKind,
|
|
24
|
+
Child,
|
|
24
25
|
Decision,
|
|
25
26
|
Goal,
|
|
26
27
|
Model,
|
|
@@ -203,6 +204,21 @@ def _source(raw: object, where: str) -> Source:
|
|
|
203
204
|
)
|
|
204
205
|
|
|
205
206
|
|
|
207
|
+
def _child(raw: object, where: str) -> Child:
|
|
208
|
+
"""Parse one flow edge: a bare id string, or a ``{id, step}`` mapping (#90).
|
|
209
|
+
|
|
210
|
+
A bare ``US-050`` stays valid (``Child("US-050")`` with no step), so existing flows load
|
|
211
|
+
unchanged; a mapping adds the optional ``step`` the edge plays in the journey.
|
|
212
|
+
"""
|
|
213
|
+
if isinstance(raw, str):
|
|
214
|
+
return Child(id=raw)
|
|
215
|
+
tbl = _as_mapping(raw, where)
|
|
216
|
+
return Child(
|
|
217
|
+
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
218
|
+
step=_opt_str(tbl.get("step"), f"{where}.step"),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
206
222
|
def _need(
|
|
207
223
|
raw: object,
|
|
208
224
|
where: str,
|
|
@@ -243,7 +259,7 @@ def _need(
|
|
|
243
259
|
for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
|
|
244
260
|
)
|
|
245
261
|
children = tuple(
|
|
246
|
-
|
|
262
|
+
_child(c, f"{where}.children[{i}]")
|
|
247
263
|
for i, c in enumerate(_as_list(tbl.get("children"), f"{where}.children"))
|
|
248
264
|
)
|
|
249
265
|
seam = tuple(
|
|
@@ -306,6 +322,8 @@ def _goal(raw: object, where: str) -> Goal:
|
|
|
306
322
|
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
307
323
|
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
308
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"),
|
|
309
327
|
)
|
|
310
328
|
|
|
311
329
|
|
|
@@ -564,9 +582,9 @@ def _check_children(needs: list[Need]) -> None:
|
|
|
564
582
|
"""Every id in a need's ``children`` must name a known need."""
|
|
565
583
|
known = {n.id for n in needs}
|
|
566
584
|
for need in needs:
|
|
567
|
-
for
|
|
568
|
-
if
|
|
569
|
-
raise LoaderError(f"need {need.id!r}: child {
|
|
585
|
+
for c in need.children:
|
|
586
|
+
if c.id not in known:
|
|
587
|
+
raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
|
|
570
588
|
|
|
571
589
|
|
|
572
590
|
def _check_withdrawal_of(needs: list[Need]) -> None:
|
|
@@ -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)
|
|
@@ -257,6 +266,21 @@ class Withdrawal:
|
|
|
257
266
|
of: str | None = None # id of the need this one duplicates (required when kind is duplicate)
|
|
258
267
|
|
|
259
268
|
|
|
269
|
+
@dataclass(frozen=True)
|
|
270
|
+
class Child:
|
|
271
|
+
"""One edge of a flow: the sub-need and, optionally, the ``step`` it plays in the journey (#90).
|
|
272
|
+
|
|
273
|
+
A flow's ``children`` are ordered, and a use case ("job") is just a flow with a ``role`` set and
|
|
274
|
+
a ``step`` on each edge (locate / execute / conclude). The step annotates the *edge*, not the
|
|
275
|
+
sub-need — the same need can be the ``execute`` step of one flow and the ``locate`` of another —
|
|
276
|
+
so it lives here, not on :class:`Need`. A bare ``US-050`` in YAML loads as ``Child("US-050")``
|
|
277
|
+
with no step, so every existing flow keeps working unchanged.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
id: str
|
|
281
|
+
step: str | None = None
|
|
282
|
+
|
|
283
|
+
|
|
260
284
|
@dataclass(frozen=True)
|
|
261
285
|
class Need:
|
|
262
286
|
"""What a named person wants, with its provenance and its proofs."""
|
|
@@ -269,7 +293,7 @@ class Need:
|
|
|
269
293
|
must: bool = False
|
|
270
294
|
sources: tuple[Source, ...] = ()
|
|
271
295
|
proofs: tuple[str, ...] = () # locators, "<adapter>:<payload>" (leaf needs)
|
|
272
|
-
children: tuple[
|
|
296
|
+
children: tuple[Child, ...] = () # ordered sub-need edges (a flow is a Need with children)
|
|
273
297
|
seam: tuple[str, ...] = () # end-to-end proof locators for a flow's between-steps behaviour
|
|
274
298
|
client: str | None = None # which client this instance is for; None = shared baseline
|
|
275
299
|
variant_of: str | None = None # id of the baseline need this specialises; None = standalone
|
|
@@ -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
|
|
@@ -293,11 +293,11 @@ class Resolver:
|
|
|
293
293
|
warnings: list[str] = []
|
|
294
294
|
child_states: list[NeedState] = []
|
|
295
295
|
declared = False
|
|
296
|
-
for
|
|
297
|
-
if
|
|
298
|
-
warnings.append(f"unknown child id '{
|
|
296
|
+
for c in need.children:
|
|
297
|
+
if c.id not in self._by_id:
|
|
298
|
+
warnings.append(f"unknown child id '{c.id}'")
|
|
299
299
|
continue
|
|
300
|
-
cr = self.resolve(
|
|
300
|
+
cr = self.resolve(c.id)
|
|
301
301
|
child_states.append(cr.state)
|
|
302
302
|
declared = declared or cr.declared
|
|
303
303
|
delivered = sum(1 for s in child_states if s is NeedState.DELIVERED)
|
|
@@ -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)."""
|
|
@@ -806,7 +826,7 @@ def handoffs(needs: list[Need]) -> list[Handoff]:
|
|
|
806
826
|
for flow in needs:
|
|
807
827
|
if not flow.children:
|
|
808
828
|
continue
|
|
809
|
-
steps = [by_id[c] for c in flow.children if c in by_id]
|
|
829
|
+
steps = [by_id[c.id] for c in flow.children if c.id in by_id]
|
|
810
830
|
for a, b in pairwise(steps):
|
|
811
831
|
if a.role and b.role and a.role != b.role:
|
|
812
832
|
out.append(Handoff(flow, a, b, a.role, b.role))
|
|
@@ -844,7 +864,7 @@ def flow_orphan_warnings(needs: list[Need]) -> set[str]:
|
|
|
844
864
|
return set()
|
|
845
865
|
in_a_flow: set[str] = set()
|
|
846
866
|
for flow in flows:
|
|
847
|
-
in_a_flow.update(flow.children)
|
|
867
|
+
in_a_flow.update(c.id for c in flow.children)
|
|
848
868
|
return {n.id for n in needs if not n.children and n.id not in in_a_flow}
|
|
849
869
|
|
|
850
870
|
|
|
@@ -365,6 +365,19 @@ def _coerce(value: str) -> object:
|
|
|
365
365
|
return value
|
|
366
366
|
|
|
367
367
|
|
|
368
|
+
def _child_item(raw: str) -> object:
|
|
369
|
+
"""Parse a ``children+=`` value into a flow edge: ``id`` stays a bare string, ``id@step`` a map.
|
|
370
|
+
|
|
371
|
+
Mirrors the loader's dual shape (#90): ``children+=US-050`` appends the bare id (unstepped),
|
|
372
|
+
``children+=US-052@execute`` appends ``{id: US-052, step: execute}``. A bare id keeps the file's
|
|
373
|
+
existing plain-string style; the ``@`` suffix mirrors the ``need:A@yes`` proof-locator idiom.
|
|
374
|
+
"""
|
|
375
|
+
head, sep, step = raw.partition("@")
|
|
376
|
+
if not sep:
|
|
377
|
+
return raw
|
|
378
|
+
return {"id": head.strip(), "step": step.strip()}
|
|
379
|
+
|
|
380
|
+
|
|
368
381
|
def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> None:
|
|
369
382
|
if "+=" in assignment:
|
|
370
383
|
key, _, raw = assignment.partition("+=")
|
|
@@ -377,7 +390,7 @@ def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> N
|
|
|
377
390
|
if not isinstance(current, list):
|
|
378
391
|
raise WriteError(f"{path.name}: {key!r} is not a list")
|
|
379
392
|
items = cast("list[object]", current)
|
|
380
|
-
items.append(raw.strip())
|
|
393
|
+
items.append(_child_item(raw.strip()) if key == "children" else raw.strip())
|
|
381
394
|
need[key] = items
|
|
382
395
|
return
|
|
383
396
|
key, sep, raw = assignment.partition("=")
|
|
@@ -109,6 +109,26 @@ def test_flow_with_proofs_raises(tmp_path: Path) -> None:
|
|
|
109
109
|
load_needs(cfg)
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def test_children_load_as_bare_id_or_stepped_mapping(tmp_path: Path) -> None:
|
|
113
|
+
# #90: a flow's children take either a bare id (unstepped) or a {id, step} mapping, mixed.
|
|
114
|
+
cfg = _config(
|
|
115
|
+
tmp_path,
|
|
116
|
+
(
|
|
117
|
+
"a.yaml",
|
|
118
|
+
"needs:\n"
|
|
119
|
+
" - id: F-1\n"
|
|
120
|
+
" text: a flow\n"
|
|
121
|
+
" children:\n"
|
|
122
|
+
" - C-1\n"
|
|
123
|
+
" - {id: C-2, step: execute}\n"
|
|
124
|
+
" - id: C-1\n text: one\n"
|
|
125
|
+
" - id: C-2\n text: two\n",
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
flow = next(n for n in load_needs(cfg) if n.id == "F-1")
|
|
129
|
+
assert [(c.id, c.step) for c in flow.children] == [("C-1", None), ("C-2", "execute")]
|
|
130
|
+
|
|
131
|
+
|
|
112
132
|
def test_variant_scope_shared_baseline_across_clients(tmp_path: Path) -> None:
|
|
113
133
|
cfg = _config(
|
|
114
134
|
tmp_path,
|
|
@@ -242,6 +262,26 @@ def test_goals_loaded_and_need_traces_up(tmp_path: Path) -> None:
|
|
|
242
262
|
assert model.needs[0].goal == "G-1"
|
|
243
263
|
|
|
244
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
|
+
|
|
245
285
|
def test_need_goal_unknown_raises(tmp_path: Path) -> None:
|
|
246
286
|
cfg = _config(tmp_path, ("a.yaml", "needs:\n - id: N-1\n text: t\n goal: NOPE\n"))
|
|
247
287
|
with pytest.raises(LoaderError, match="goal 'NOPE' is not a known goal id"):
|
|
@@ -14,7 +14,7 @@ from dataclasses import dataclass, replace
|
|
|
14
14
|
from musil import Action, Model, check, explore, reaches
|
|
15
15
|
|
|
16
16
|
from prodc.adapters import Adapter, ProofResult
|
|
17
|
-
from prodc.model import Decision, Need, NeedState, ProofState, Withdrawal, board_needs
|
|
17
|
+
from prodc.model import Child, Decision, Need, NeedState, ProofState, Withdrawal, board_needs
|
|
18
18
|
from prodc.status import Resolver, active_decisions
|
|
19
19
|
|
|
20
20
|
|
|
@@ -141,7 +141,7 @@ def flow_state(f: Flow) -> NeedState:
|
|
|
141
141
|
"""The NeedState prodc's real aggregate gives this set of children + seam."""
|
|
142
142
|
children = [_child_need(f"c{i}", s) for i, s in enumerate(f.children)]
|
|
143
143
|
seam = tuple(f"stub:{s}" for s in f.seam if s != "none")
|
|
144
|
-
flow = Need(id="flow", text="f", children=tuple(c.id for c in children), seam=seam)
|
|
144
|
+
flow = Need(id="flow", text="f", children=tuple(Child(c.id) for c in children), seam=seam)
|
|
145
145
|
return Resolver([flow, *children], _ADAPTERS).resolve("flow").state
|
|
146
146
|
|
|
147
147
|
|
|
@@ -6,8 +6,24 @@ from dataclasses import dataclass
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
|
|
9
|
-
from prodc.model import Need, NeedState, ProofState, Source, SourceKind
|
|
10
|
-
from prodc.status import resolve
|
|
9
|
+
from prodc.model import Child, Need, NeedState, ProofState, Source, SourceKind
|
|
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)
|
|
@@ -52,7 +68,7 @@ def test_flow_aggregates_two_of_three() -> None:
|
|
|
52
68
|
Need(id="C-2", text="two", proofs=("web:2",)),
|
|
53
69
|
Need(id="C-3", text="three", proofs=()), # a HOLE
|
|
54
70
|
)
|
|
55
|
-
flow = Need(id="F-1", text="a flow", children=("C-1", "C-2", "C-3"))
|
|
71
|
+
flow = Need(id="F-1", text="a flow", children=(Child("C-1"), Child("C-2"), Child("C-3")))
|
|
56
72
|
results = {r.need.id: r for r in resolve([flow, *children], {"web": _delivered()})}
|
|
57
73
|
flow_result = results["F-1"]
|
|
58
74
|
assert flow_result.state is NeedState.PENDING # not all delivered → pending
|
|
@@ -234,7 +250,7 @@ def test_coverage_counts_leaves_not_flow_parents() -> None:
|
|
|
234
250
|
from prodc.status import summarize
|
|
235
251
|
|
|
236
252
|
needs = [
|
|
237
|
-
Need(id="F-1", text="roast day flow", children=("L-1", "L-2")),
|
|
253
|
+
Need(id="F-1", text="roast day flow", children=(Child("L-1"), Child("L-2"))),
|
|
238
254
|
Need(id="L-1", text="step one", proofs=("x:1",)),
|
|
239
255
|
Need(id="L-2", text="step two", proofs=("x:1",)),
|
|
240
256
|
]
|
|
@@ -296,7 +312,7 @@ def test_flow_orphan_warning() -> None:
|
|
|
296
312
|
from prodc.status import flow_orphan_warnings
|
|
297
313
|
|
|
298
314
|
needs = [
|
|
299
|
-
Need(id="F-1", text="a flow", children=("L-1",)),
|
|
315
|
+
Need(id="F-1", text="a flow", children=(Child("L-1"),)),
|
|
300
316
|
Need(id="L-1", text="in the flow"),
|
|
301
317
|
Need(id="L-2", text="in no flow"),
|
|
302
318
|
]
|
|
@@ -354,7 +370,7 @@ def test_flow_seam_proof_gates_delivered() -> None:
|
|
|
354
370
|
# all children delivered but the seam proof pending → flow is PENDING, not DELIVERED (#44)
|
|
355
371
|
from prodc.adapters import Adapter
|
|
356
372
|
|
|
357
|
-
flow = Need(id="F-1", text="roast day", children=("L-1", "L-2"), seam=("web:seam#E2E",))
|
|
373
|
+
flow = Need(id="F-1", text="roast day", children=(Child("L-1"), Child("L-2")), seam=("web:seam#E2E",))
|
|
358
374
|
children = [
|
|
359
375
|
Need(id="L-1", text="a", proofs=("x:1",)),
|
|
360
376
|
Need(id="L-2", text="b", proofs=("x:1",)),
|
|
@@ -368,14 +384,14 @@ def test_flow_seam_proof_gates_delivered() -> None:
|
|
|
368
384
|
|
|
369
385
|
|
|
370
386
|
def test_flow_seam_green_delivers() -> None:
|
|
371
|
-
flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("web:seam#E2E",))
|
|
387
|
+
flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("web:seam#E2E",))
|
|
372
388
|
child = Need(id="L-1", text="a", proofs=("x:1",))
|
|
373
389
|
by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered(), "web": _delivered()})}
|
|
374
390
|
assert by_id["F-1"].state is NeedState.DELIVERED
|
|
375
391
|
|
|
376
392
|
|
|
377
393
|
def test_flow_seam_broken_breaks_flow() -> None:
|
|
378
|
-
flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("nope:x",))
|
|
394
|
+
flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("nope:x",))
|
|
379
395
|
child = Need(id="L-1", text="a", proofs=("x:1",))
|
|
380
396
|
by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered()})}
|
|
381
397
|
assert by_id["F-1"].state is NeedState.BROKEN # no 'nope' adapter → seam BROKEN
|
|
@@ -275,6 +275,33 @@ def test_delete_need_removes_the_block_keeping_others(tmp_path: Path) -> None:
|
|
|
275
275
|
assert "# keep me" in text # header comment survives the splice
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
def test_children_append_stepped_edge(tmp_path: Path) -> None:
|
|
279
|
+
# #90: `children+=id@step` appends a {id, step} edge; a bare id stays a plain string.
|
|
280
|
+
body = (
|
|
281
|
+
"needs:\n"
|
|
282
|
+
" - id: F-1\n text: a flow\n children: [N-2]\n"
|
|
283
|
+
" - id: N-2\n text: step two\n proofs: ['x:1']\n"
|
|
284
|
+
" - id: N-3\n text: step three\n proofs: ['x:2']\n"
|
|
285
|
+
)
|
|
286
|
+
cfg = _proj(tmp_path, body)
|
|
287
|
+
set_field(cfg, "F-1", "children+=N-3@conclude", expect_sha=None, dry_run=False)
|
|
288
|
+
flow = next(n for n in load(cfg).needs if n.id == "F-1")
|
|
289
|
+
assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", "conclude")]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_children_append_bare_id(tmp_path: Path) -> None:
|
|
293
|
+
body = (
|
|
294
|
+
"needs:\n"
|
|
295
|
+
" - id: F-1\n text: a flow\n children: [N-2]\n"
|
|
296
|
+
" - id: N-2\n text: step two\n proofs: ['x:1']\n"
|
|
297
|
+
" - id: N-3\n text: step three\n proofs: ['x:2']\n"
|
|
298
|
+
)
|
|
299
|
+
cfg = _proj(tmp_path, body)
|
|
300
|
+
set_field(cfg, "F-1", "children+=N-3", expect_sha=None, dry_run=False)
|
|
301
|
+
flow = next(n for n in load(cfg).needs if n.id == "F-1")
|
|
302
|
+
assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", None)]
|
|
303
|
+
|
|
304
|
+
|
|
278
305
|
def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
|
|
279
306
|
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
|
|
280
307
|
with pytest.raises(WriteError, match="no need 'N-9'"):
|
|
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
|