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.
Files changed (65) hide show
  1. {prodc-0.4.2 → prodc-0.4.4}/PKG-INFO +1 -1
  2. {prodc-0.4.2 → prodc-0.4.4}/pyproject.toml +1 -1
  3. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/app.py +7 -0
  4. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/commands.py +1 -0
  5. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/render.py +16 -4
  6. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/serialize.py +4 -1
  7. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/config.py +11 -0
  8. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/loader.py +15 -0
  9. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/model.py +17 -1
  10. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/report.py +10 -9
  11. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/report_html.py +11 -2
  12. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/status.py +20 -0
  13. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/writer.py +5 -1
  14. {prodc-0.4.2 → prodc-0.4.4}/tests/test_loader.py +20 -0
  15. {prodc-0.4.2 → prodc-0.4.4}/tests/test_principles.py +48 -1
  16. {prodc-0.4.2 → prodc-0.4.4}/tests/test_status.py +17 -1
  17. {prodc-0.4.2 → prodc-0.4.4}/.gitignore +0 -0
  18. {prodc-0.4.2 → prodc-0.4.4}/CHANGELOG.md +0 -0
  19. {prodc-0.4.2 → prodc-0.4.4}/LICENSE +0 -0
  20. {prodc-0.4.2 → prodc-0.4.4}/README.md +0 -0
  21. {prodc-0.4.2 → prodc-0.4.4}/examples/quickstart/features/orders.feature +0 -0
  22. {prodc-0.4.2 → prodc-0.4.4}/examples/quickstart/needs/jordan.yaml +0 -0
  23. {prodc-0.4.2 → prodc-0.4.4}/examples/quickstart/prodc.toml +0 -0
  24. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/__init__.py +0 -0
  25. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/adapters.py +0 -0
  26. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/agent.md +0 -0
  27. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/board.py +0 -0
  28. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/callbacks.py +0 -0
  29. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/__init__.py +0 -0
  30. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/cli/common.py +0 -0
  31. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/dedup.py +0 -0
  32. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/guide.md +0 -0
  33. {prodc-0.4.2 → prodc-0.4.4}/src/prodc/py.typed +0 -0
  34. {prodc-0.4.2 → prodc-0.4.4}/tests/test_adapters.py +0 -0
  35. {prodc-0.4.2 → prodc-0.4.4}/tests/test_answer.py +0 -0
  36. {prodc-0.4.2 → prodc-0.4.4}/tests/test_callbacks.py +0 -0
  37. {prodc-0.4.2 → prodc-0.4.4}/tests/test_citation.py +0 -0
  38. {prodc-0.4.2 → prodc-0.4.4}/tests/test_claim_gating.py +0 -0
  39. {prodc-0.4.2 → prodc-0.4.4}/tests/test_cli.py +0 -0
  40. {prodc-0.4.2 → prodc-0.4.4}/tests/test_command_hook.py +0 -0
  41. {prodc-0.4.2 → prodc-0.4.4}/tests/test_contradicts.py +0 -0
  42. {prodc-0.4.2 → prodc-0.4.4}/tests/test_decisions.py +0 -0
  43. {prodc-0.4.2 → prodc-0.4.4}/tests/test_dedup.py +0 -0
  44. {prodc-0.4.2 → prodc-0.4.4}/tests/test_description.py +0 -0
  45. {prodc-0.4.2 → prodc-0.4.4}/tests/test_drafts.py +0 -0
  46. {prodc-0.4.2 → prodc-0.4.4}/tests/test_evalgate.py +0 -0
  47. {prodc-0.4.2 → prodc-0.4.4}/tests/test_external_adapter.py +0 -0
  48. {prodc-0.4.2 → prodc-0.4.4}/tests/test_gitlab_source.py +0 -0
  49. {prodc-0.4.2 → prodc-0.4.4}/tests/test_importance.py +0 -0
  50. {prodc-0.4.2 → prodc-0.4.4}/tests/test_models.py +0 -0
  51. {prodc-0.4.2 → prodc-0.4.4}/tests/test_needref.py +0 -0
  52. {prodc-0.4.2 → prodc-0.4.4}/tests/test_relink.py +0 -0
  53. {prodc-0.4.2 → prodc-0.4.4}/tests/test_report.py +0 -0
  54. {prodc-0.4.2 → prodc-0.4.4}/tests/test_report_html.py +0 -0
  55. {prodc-0.4.2 → prodc-0.4.4}/tests/test_review.py +0 -0
  56. {prodc-0.4.2 → prodc-0.4.4}/tests/test_roles.py +0 -0
  57. {prodc-0.4.2 → prodc-0.4.4}/tests/test_source_role.py +0 -0
  58. {prodc-0.4.2 → prodc-0.4.4}/tests/test_sources.py +0 -0
  59. {prodc-0.4.2 → prodc-0.4.4}/tests/test_staleness.py +0 -0
  60. {prodc-0.4.2 → prodc-0.4.4}/tests/test_suggest.py +0 -0
  61. {prodc-0.4.2 → prodc-0.4.4}/tests/test_variants.py +0 -0
  62. {prodc-0.4.2 → prodc-0.4.4}/tests/test_who_variants.py +0 -0
  63. {prodc-0.4.2 → prodc-0.4.4}/tests/test_withdrawn.py +0 -0
  64. {prodc-0.4.2 → prodc-0.4.4}/tests/test_writer.py +0 -0
  65. {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.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.4.2"
3
+ version = "0.4.4"
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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,
@@ -557,6 +557,7 @@ def cmd_add(args: argparse.Namespace) -> int:
557
557
  text,
558
558
  file=args.file,
559
559
  revised_at=date.today().isoformat(),
560
+ scope=args.scope,
560
561
  sources=sources,
561
562
  expect_sha=args.expect_sha,
562
563
  dry_run=args.dry_run,
@@ -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
- out.append(f" {goal.id:<12} {gsum.delivered}/{gsum.total} ({pct}%){metric} {goal.text}")
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 product vision — held as given):"]
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
- out.append(f" {a.id:<6} {rev} {a.text}")
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} | {gsum.delivered}/{gsum.total} ({round(gsum.coverage * 100)}%) "
104
- f"| {goal.text} |"
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 product vision — held as given)",
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.revised_at or '—'} "
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": [{"id": g.id, "text": g.text, **_cov(s.delivered, s.total)} for g, s in goal_cov],
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, "revised_at": revised_at}
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 product vision — held as given):" in out
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