prodc 0.4.2__tar.gz → 0.4.4__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.4}/PKG-INFO +1 -1
- {prodc-0.4.2 → prodc-0.4.4}/pyproject.toml +1 -1
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/app.py +7 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/commands.py +1 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/render.py +16 -4
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/serialize.py +4 -1
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/config.py +11 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/loader.py +15 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/model.py +17 -1
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/report.py +10 -9
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/report_html.py +11 -2
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/status.py +20 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/writer.py +5 -1
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_loader.py +20 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_principles.py +48 -1
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_status.py +17 -1
- {prodc-0.4.2 → prodc-0.4.4}/.gitignore +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/CHANGELOG.md +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/LICENSE +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/README.md +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/__init__.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/adapters.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/agent.md +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/board.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/dedup.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/guide.md +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/src/prodc/py.typed +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_adapters.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_answer.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_callbacks.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_citation.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_cli.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_command_hook.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_contradicts.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_decisions.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_dedup.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_description.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_drafts.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_evalgate.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_importance.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_models.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_needref.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_relink.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_report.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_report_html.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_review.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_roles.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_source_role.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_sources.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_staleness.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_suggest.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_variants.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_who_variants.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_withdrawn.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/tests/test_writer.py +0 -0
- {prodc-0.4.2 → prodc-0.4.4}/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.4
|
|
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
|
|
@@ -181,6 +181,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
181
181
|
help="principle only: promote a need into a principle — copy its text (unless --text "
|
|
182
182
|
"overrides) and its sources as the provenance that surfaced it; the need stays on the board",
|
|
183
183
|
)
|
|
184
|
+
ad.add_argument(
|
|
185
|
+
"--scope",
|
|
186
|
+
default="product",
|
|
187
|
+
metavar="SCOPE",
|
|
188
|
+
help="principle only: its scope — product (default), design, visual, engineering; "
|
|
189
|
+
"must be declared in [principles].scopes",
|
|
190
|
+
)
|
|
184
191
|
ad.add_argument(
|
|
185
192
|
"--needs",
|
|
186
193
|
default=None,
|
|
@@ -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}")
|
|
@@ -274,11 +285,12 @@ def render_principles(reports: list[PrincipleReport]) -> str:
|
|
|
274
285
|
An principle is held as given, not validated; ``declined`` are the needs withdrawn citing it, and
|
|
275
286
|
``stale`` (the ripple) are those decided before its latest ``revised_at`` — flagged to re-review.
|
|
276
287
|
"""
|
|
277
|
-
out = ["\nprinciples (the
|
|
288
|
+
out = ["\nprinciples (the vision — held as given):"]
|
|
278
289
|
for rep in reports:
|
|
279
290
|
a = rep.principle
|
|
280
291
|
rev = f"revised {a.revised_at}" if a.revised_at else "undated"
|
|
281
|
-
|
|
292
|
+
scope = "" if a.scope == "product" else f"[{a.scope}] "
|
|
293
|
+
out.append(f" {a.id:<6} {scope}{rev} {a.text}")
|
|
282
294
|
if rep.declined:
|
|
283
295
|
out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
|
|
284
296
|
if rep.stale:
|
|
@@ -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,
|
|
@@ -119,6 +119,7 @@ class Config:
|
|
|
119
119
|
default_draft: bool = False # [add].default_draft — `add` stages as draft by default (#60)
|
|
120
120
|
callbacks: tuple[Callback, ...] = () # [[callbacks.hooks]] — lifecycle callbacks (#71)
|
|
121
121
|
callback_state_file: str = ".prodc/state.json" # [callbacks].state_file — the state ledger
|
|
122
|
+
principle_scopes: tuple[str, ...] = ("product",) # [principles].scopes — allowed scopes (#88)
|
|
122
123
|
|
|
123
124
|
def importance_label(self, level: int | None) -> str | None:
|
|
124
125
|
"""The declared label for an importance level, or None if unset/undeclared (#59)."""
|
|
@@ -302,6 +303,15 @@ class Config:
|
|
|
302
303
|
|
|
303
304
|
callbacks, callback_state_file = _callbacks(raw)
|
|
304
305
|
|
|
306
|
+
principles_tbl = _as_dict(raw.get("principles", {}), "[principles]")
|
|
307
|
+
if "scopes" in principles_tbl:
|
|
308
|
+
scopes = _as_str_list(principles_tbl["scopes"], "[principles].scopes")
|
|
309
|
+
if not scopes:
|
|
310
|
+
raise ConfigError("[principles].scopes must list at least one scope")
|
|
311
|
+
principle_scopes = tuple(scopes)
|
|
312
|
+
else:
|
|
313
|
+
principle_scopes = ("product",)
|
|
314
|
+
|
|
305
315
|
return cls(
|
|
306
316
|
root=root,
|
|
307
317
|
need_globs=need_globs,
|
|
@@ -315,4 +325,5 @@ class Config:
|
|
|
315
325
|
default_draft=default_draft,
|
|
316
326
|
callbacks=callbacks,
|
|
317
327
|
callback_state_file=callback_state_file,
|
|
328
|
+
principle_scopes=principle_scopes,
|
|
318
329
|
)
|
|
@@ -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
|
|
|
@@ -347,6 +349,7 @@ def _principle(raw: object, where: str) -> Principle:
|
|
|
347
349
|
return Principle(
|
|
348
350
|
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
349
351
|
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
352
|
+
scope=_opt_str(tbl.get("scope"), f"{where}.scope") or "product",
|
|
350
353
|
revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
|
|
351
354
|
sources=tuple(
|
|
352
355
|
_source(s, f"{where}.sources[{i}]")
|
|
@@ -532,6 +535,7 @@ def load(config: Config) -> Model:
|
|
|
532
535
|
_check_withdrawal_of(acc.needs)
|
|
533
536
|
_check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
|
|
534
537
|
_check_decisions(acc.decisions, {n.id for n in acc.needs}, {p.id for p in acc.principles})
|
|
538
|
+
_check_principle_scopes(acc.principles, config.principle_scopes)
|
|
535
539
|
acc.needs = _symmetrize_contradicts(acc.needs)
|
|
536
540
|
return Model(
|
|
537
541
|
needs=tuple(acc.needs),
|
|
@@ -585,6 +589,17 @@ def _check_children(needs: list[Need]) -> None:
|
|
|
585
589
|
raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
|
|
586
590
|
|
|
587
591
|
|
|
592
|
+
def _check_principle_scopes(principles: list[Principle], allowed: tuple[str, ...]) -> None:
|
|
593
|
+
"""Every principle's ``scope`` must be one declared in ``[principles].scopes`` (#88)."""
|
|
594
|
+
allowed_set = set(allowed)
|
|
595
|
+
for p in principles:
|
|
596
|
+
if p.scope not in allowed_set:
|
|
597
|
+
raise LoaderError(
|
|
598
|
+
f"principle {p.id!r}: scope {p.scope!r} is not declared in "
|
|
599
|
+
f"[principles].scopes ({', '.join(allowed)})"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
588
603
|
def _check_withdrawal_of(needs: list[Need]) -> None:
|
|
589
604
|
"""A withdrawal's ``of`` must name a known need (never itself); required when kind is duplicate.
|
|
590
605
|
|
|
@@ -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)
|
|
@@ -153,11 +162,18 @@ class Principle:
|
|
|
153
162
|
client), and ``revised_at`` dates its latest statement. A decision taken under an older version
|
|
154
163
|
of the principle is flagged for re-review when the principle is revised — the ripple. A separate noun
|
|
155
164
|
(like :class:`Role`), not a Need marker: a principle is a stance, not what a person wants, so it
|
|
156
|
-
never enters coverage, the plan, or the opinion check.
|
|
165
|
+
never enters coverage, the plan, or the opinion check — in every ``scope`` (#88).
|
|
166
|
+
|
|
167
|
+
``scope`` says which kind of commitment it is — product, design, visual, engineering — so design,
|
|
168
|
+
visual and engineering principles live on the board beside product ones instead of in prose docs
|
|
169
|
+
and code comments, where a Decision or an ``out_of_scope`` withdrawal cannot cite them. The scope
|
|
170
|
+
must be one declared in ``[principles].scopes`` (default ``["product"]``); an undeclared scope is a
|
|
171
|
+
load error the author owns.
|
|
157
172
|
"""
|
|
158
173
|
|
|
159
174
|
id: str
|
|
160
175
|
text: str
|
|
176
|
+
scope: str = "product" # product/design/visual/engineering — declared in [principles].scopes (#88)
|
|
161
177
|
revised_at: str | None = None # ISO date the principle was last stated or revised
|
|
162
178
|
sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
|
|
163
179
|
|
|
@@ -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("")
|
|
@@ -153,18 +154,18 @@ def render_markdown(
|
|
|
153
154
|
|
|
154
155
|
if model.principles:
|
|
155
156
|
out += [
|
|
156
|
-
"## Principles (the
|
|
157
|
+
"## Principles (the vision — held as given)",
|
|
157
158
|
"",
|
|
158
|
-
"| principle | stated | needs declined under it |",
|
|
159
|
-
"
|
|
159
|
+
"| principle | scope | stated | needs declined under it |",
|
|
160
|
+
"|---|---|---|---|",
|
|
160
161
|
]
|
|
161
162
|
for rep in principle_report(list(model.needs), list(model.principles)):
|
|
162
163
|
text = rep.principle.text.replace("|", "\\|")
|
|
163
164
|
declined = ", ".join(n.id for n in rep.declined) or "—"
|
|
164
165
|
stale = f" _(re-review {', '.join(n.id for n in rep.stale)})_" if rep.stale else ""
|
|
165
166
|
out.append(
|
|
166
|
-
f"| {rep.principle.id}: {text} | {rep.principle.
|
|
167
|
-
f"| {declined}{stale} |"
|
|
167
|
+
f"| {rep.principle.id}: {text} | {rep.principle.scope} "
|
|
168
|
+
f"| {rep.principle.revised_at or '—'} | {declined}{stale} |"
|
|
168
169
|
)
|
|
169
170
|
out.append("")
|
|
170
171
|
|
|
@@ -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)."""
|
|
@@ -914,6 +914,7 @@ def add_principle(
|
|
|
914
914
|
*,
|
|
915
915
|
file: str,
|
|
916
916
|
revised_at: str,
|
|
917
|
+
scope: str = "product",
|
|
917
918
|
sources: tuple[Source, ...] = (),
|
|
918
919
|
expect_sha: str | None = None,
|
|
919
920
|
dry_run: bool,
|
|
@@ -943,7 +944,10 @@ def add_principle(
|
|
|
943
944
|
for a in principles
|
|
944
945
|
):
|
|
945
946
|
raise WriteError(f"principle {principle_id!r} already exists in {target.name}")
|
|
946
|
-
entry: dict[str, object] = {"id": principle_id, "text": text
|
|
947
|
+
entry: dict[str, object] = {"id": principle_id, "text": text}
|
|
948
|
+
if scope != "product":
|
|
949
|
+
entry["scope"] = scope # written only when non-default, to keep the common case clean
|
|
950
|
+
entry["revised_at"] = revised_at
|
|
947
951
|
if sources:
|
|
948
952
|
entry["sources"] = [_source_dict(s) for s in sources]
|
|
949
953
|
principles.append(entry)
|
|
@@ -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"):
|
|
@@ -132,6 +132,53 @@ principles:
|
|
|
132
132
|
load(Config.load(_project(tmp_path, needs=needs)))
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
# --- scope (#88) ------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
_SCOPED_TOML = """
|
|
138
|
+
[prodc]
|
|
139
|
+
needs = ["needs/*.yaml"]
|
|
140
|
+
|
|
141
|
+
[principles]
|
|
142
|
+
scopes = ["product", "visual", "engineering"]
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_principle_scope_defaults_to_product(tmp_path: Path) -> None:
|
|
147
|
+
needs = "principles:\n - id: P-1\n text: a principle\n"
|
|
148
|
+
model = load(Config.load(_project(tmp_path, needs=needs)))
|
|
149
|
+
assert model.principles[0].scope == "product"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_declared_scope_loads(tmp_path: Path) -> None:
|
|
153
|
+
needs = "principles:\n - id: P-1\n text: colour is load-bearing\n scope: visual\n"
|
|
154
|
+
model = load(Config.load(_project(tmp_path, toml=_SCOPED_TOML, needs=needs)))
|
|
155
|
+
assert model.principles[0].scope == "visual"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_undeclared_scope_rejected(tmp_path: Path) -> None:
|
|
159
|
+
# default [principles].scopes is just ["product"], so "visual" is undeclared here.
|
|
160
|
+
needs = "principles:\n - id: P-1\n text: a principle\n scope: visual\n"
|
|
161
|
+
with pytest.raises(LoaderError, match="scope 'visual' is not declared"):
|
|
162
|
+
load(Config.load(_project(tmp_path, needs=needs)))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_add_principle_writes_scope(tmp_path: Path) -> None:
|
|
166
|
+
from prodc.writer import add_principle
|
|
167
|
+
|
|
168
|
+
cfg = Config.load(_project(tmp_path, toml=_SCOPED_TOML, needs="principles:\n - id: P-0\n text: seed\n"))
|
|
169
|
+
add_principle(
|
|
170
|
+
cfg,
|
|
171
|
+
"P-9",
|
|
172
|
+
"colour is load-bearing",
|
|
173
|
+
file="needs/a.yaml",
|
|
174
|
+
revised_at=date.today().isoformat(),
|
|
175
|
+
scope="visual",
|
|
176
|
+
dry_run=False,
|
|
177
|
+
)
|
|
178
|
+
model = load(cfg)
|
|
179
|
+
assert next(p for p in model.principles if p.id == "P-9").scope == "visual"
|
|
180
|
+
|
|
181
|
+
|
|
135
182
|
# --- principle_report (declined / stale) ------------------------------------
|
|
136
183
|
|
|
137
184
|
|
|
@@ -275,7 +322,7 @@ def test_status_text_shows_principles_section(
|
|
|
275
322
|
|
|
276
323
|
main(["status", "--config", str(_project(tmp_path))])
|
|
277
324
|
out = capsys.readouterr().out
|
|
278
|
-
assert "principles (the
|
|
325
|
+
assert "principles (the vision — held as given):" in out
|
|
279
326
|
assert "P-1" in out and "revised 2026-10-15" in out
|
|
280
327
|
assert "declined: N-7, N-9" in out
|
|
281
328
|
assert "decided before the latest revision: N-7 — re-review" in out
|
|
@@ -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
|