prodc 0.4.3__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.3 → prodc-0.4.4}/PKG-INFO +1 -1
  2. {prodc-0.4.3 → prodc-0.4.4}/pyproject.toml +1 -1
  3. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/app.py +7 -0
  4. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/commands.py +1 -0
  5. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/render.py +3 -2
  6. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/config.py +11 -0
  7. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/loader.py +13 -0
  8. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/model.py +8 -1
  9. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/report.py +5 -5
  10. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/writer.py +5 -1
  11. {prodc-0.4.3 → prodc-0.4.4}/tests/test_principles.py +48 -1
  12. {prodc-0.4.3 → prodc-0.4.4}/.gitignore +0 -0
  13. {prodc-0.4.3 → prodc-0.4.4}/CHANGELOG.md +0 -0
  14. {prodc-0.4.3 → prodc-0.4.4}/LICENSE +0 -0
  15. {prodc-0.4.3 → prodc-0.4.4}/README.md +0 -0
  16. {prodc-0.4.3 → prodc-0.4.4}/examples/quickstart/features/orders.feature +0 -0
  17. {prodc-0.4.3 → prodc-0.4.4}/examples/quickstart/needs/jordan.yaml +0 -0
  18. {prodc-0.4.3 → prodc-0.4.4}/examples/quickstart/prodc.toml +0 -0
  19. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/__init__.py +0 -0
  20. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/adapters.py +0 -0
  21. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/agent.md +0 -0
  22. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/board.py +0 -0
  23. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/callbacks.py +0 -0
  24. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/__init__.py +0 -0
  25. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/common.py +0 -0
  26. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/cli/serialize.py +0 -0
  27. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/dedup.py +0 -0
  28. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/guide.md +0 -0
  29. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/py.typed +0 -0
  30. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/report_html.py +0 -0
  31. {prodc-0.4.3 → prodc-0.4.4}/src/prodc/status.py +0 -0
  32. {prodc-0.4.3 → prodc-0.4.4}/tests/test_adapters.py +0 -0
  33. {prodc-0.4.3 → prodc-0.4.4}/tests/test_answer.py +0 -0
  34. {prodc-0.4.3 → prodc-0.4.4}/tests/test_callbacks.py +0 -0
  35. {prodc-0.4.3 → prodc-0.4.4}/tests/test_citation.py +0 -0
  36. {prodc-0.4.3 → prodc-0.4.4}/tests/test_claim_gating.py +0 -0
  37. {prodc-0.4.3 → prodc-0.4.4}/tests/test_cli.py +0 -0
  38. {prodc-0.4.3 → prodc-0.4.4}/tests/test_command_hook.py +0 -0
  39. {prodc-0.4.3 → prodc-0.4.4}/tests/test_contradicts.py +0 -0
  40. {prodc-0.4.3 → prodc-0.4.4}/tests/test_decisions.py +0 -0
  41. {prodc-0.4.3 → prodc-0.4.4}/tests/test_dedup.py +0 -0
  42. {prodc-0.4.3 → prodc-0.4.4}/tests/test_description.py +0 -0
  43. {prodc-0.4.3 → prodc-0.4.4}/tests/test_drafts.py +0 -0
  44. {prodc-0.4.3 → prodc-0.4.4}/tests/test_evalgate.py +0 -0
  45. {prodc-0.4.3 → prodc-0.4.4}/tests/test_external_adapter.py +0 -0
  46. {prodc-0.4.3 → prodc-0.4.4}/tests/test_gitlab_source.py +0 -0
  47. {prodc-0.4.3 → prodc-0.4.4}/tests/test_importance.py +0 -0
  48. {prodc-0.4.3 → prodc-0.4.4}/tests/test_loader.py +0 -0
  49. {prodc-0.4.3 → prodc-0.4.4}/tests/test_models.py +0 -0
  50. {prodc-0.4.3 → prodc-0.4.4}/tests/test_needref.py +0 -0
  51. {prodc-0.4.3 → prodc-0.4.4}/tests/test_relink.py +0 -0
  52. {prodc-0.4.3 → prodc-0.4.4}/tests/test_report.py +0 -0
  53. {prodc-0.4.3 → prodc-0.4.4}/tests/test_report_html.py +0 -0
  54. {prodc-0.4.3 → prodc-0.4.4}/tests/test_review.py +0 -0
  55. {prodc-0.4.3 → prodc-0.4.4}/tests/test_roles.py +0 -0
  56. {prodc-0.4.3 → prodc-0.4.4}/tests/test_source_role.py +0 -0
  57. {prodc-0.4.3 → prodc-0.4.4}/tests/test_sources.py +0 -0
  58. {prodc-0.4.3 → prodc-0.4.4}/tests/test_staleness.py +0 -0
  59. {prodc-0.4.3 → prodc-0.4.4}/tests/test_status.py +0 -0
  60. {prodc-0.4.3 → prodc-0.4.4}/tests/test_suggest.py +0 -0
  61. {prodc-0.4.3 → prodc-0.4.4}/tests/test_variants.py +0 -0
  62. {prodc-0.4.3 → prodc-0.4.4}/tests/test_who_variants.py +0 -0
  63. {prodc-0.4.3 → prodc-0.4.4}/tests/test_withdrawn.py +0 -0
  64. {prodc-0.4.3 → prodc-0.4.4}/tests/test_writer.py +0 -0
  65. {prodc-0.4.3 → 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
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.3"
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,
@@ -285,11 +285,12 @@ def render_principles(reports: list[PrincipleReport]) -> str:
285
285
  An principle is held as given, not validated; ``declined`` are the needs withdrawn citing it, and
286
286
  ``stale`` (the ripple) are those decided before its latest ``revised_at`` — flagged to re-review.
287
287
  """
288
- out = ["\nprinciples (the product vision — held as given):"]
288
+ out = ["\nprinciples (the vision — held as given):"]
289
289
  for rep in reports:
290
290
  a = rep.principle
291
291
  rev = f"revised {a.revised_at}" if a.revised_at else "undated"
292
- 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}")
293
294
  if rep.declined:
294
295
  out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
295
296
  if rep.stale:
@@ -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
  )
@@ -349,6 +349,7 @@ def _principle(raw: object, where: str) -> Principle:
349
349
  return Principle(
350
350
  id=_as_str(tbl.get("id"), f"{where}.id"),
351
351
  text=_as_str(tbl.get("text"), f"{where}.text"),
352
+ scope=_opt_str(tbl.get("scope"), f"{where}.scope") or "product",
352
353
  revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
353
354
  sources=tuple(
354
355
  _source(s, f"{where}.sources[{i}]")
@@ -534,6 +535,7 @@ def load(config: Config) -> Model:
534
535
  _check_withdrawal_of(acc.needs)
535
536
  _check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
536
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)
537
539
  acc.needs = _symmetrize_contradicts(acc.needs)
538
540
  return Model(
539
541
  needs=tuple(acc.needs),
@@ -587,6 +589,17 @@ def _check_children(needs: list[Need]) -> None:
587
589
  raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
588
590
 
589
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
+
590
603
  def _check_withdrawal_of(needs: list[Need]) -> None:
591
604
  """A withdrawal's ``of`` must name a known need (never itself); required when kind is duplicate.
592
605
 
@@ -162,11 +162,18 @@ class Principle:
162
162
  client), and ``revised_at`` dates its latest statement. A decision taken under an older version
163
163
  of the principle is flagged for re-review when the principle is revised — the ripple. A separate noun
164
164
  (like :class:`Role`), not a Need marker: a principle is a stance, not what a person wants, so it
165
- 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.
166
172
  """
167
173
 
168
174
  id: str
169
175
  text: str
176
+ scope: str = "product" # product/design/visual/engineering — declared in [principles].scopes (#88)
170
177
  revised_at: str | None = None # ISO date the principle was last stated or revised
171
178
  sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
172
179
 
@@ -154,18 +154,18 @@ def render_markdown(
154
154
 
155
155
  if model.principles:
156
156
  out += [
157
- "## Principles (the product vision — held as given)",
157
+ "## Principles (the vision — held as given)",
158
158
  "",
159
- "| principle | stated | needs declined under it |",
160
- "|---|---|---|",
159
+ "| principle | scope | stated | needs declined under it |",
160
+ "|---|---|---|---|",
161
161
  ]
162
162
  for rep in principle_report(list(model.needs), list(model.principles)):
163
163
  text = rep.principle.text.replace("|", "\\|")
164
164
  declined = ", ".join(n.id for n in rep.declined) or "—"
165
165
  stale = f" _(re-review {', '.join(n.id for n in rep.stale)})_" if rep.stale else ""
166
166
  out.append(
167
- f"| {rep.principle.id}: {text} | {rep.principle.revised_at or '—'} "
168
- f"| {declined}{stale} |"
167
+ f"| {rep.principle.id}: {text} | {rep.principle.scope} "
168
+ f"| {rep.principle.revised_at or '—'} | {declined}{stale} |"
169
169
  )
170
170
  out.append("")
171
171
 
@@ -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)
@@ -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
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