prodc 0.4.4__tar.gz → 0.4.5__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.4 → prodc-0.4.5}/PKG-INFO +1 -1
  2. {prodc-0.4.4 → prodc-0.4.5}/pyproject.toml +1 -1
  3. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/board.py +1 -1
  4. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/app.py +16 -0
  5. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/commands.py +24 -5
  6. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/render.py +23 -1
  7. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/loader.py +15 -2
  8. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/model.py +26 -0
  9. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/report.py +5 -5
  10. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/status.py +47 -4
  11. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/writer.py +6 -0
  12. {prodc-0.4.4 → prodc-0.4.5}/tests/test_principles.py +77 -2
  13. {prodc-0.4.4 → prodc-0.4.5}/.gitignore +0 -0
  14. {prodc-0.4.4 → prodc-0.4.5}/CHANGELOG.md +0 -0
  15. {prodc-0.4.4 → prodc-0.4.5}/LICENSE +0 -0
  16. {prodc-0.4.4 → prodc-0.4.5}/README.md +0 -0
  17. {prodc-0.4.4 → prodc-0.4.5}/examples/quickstart/features/orders.feature +0 -0
  18. {prodc-0.4.4 → prodc-0.4.5}/examples/quickstart/needs/jordan.yaml +0 -0
  19. {prodc-0.4.4 → prodc-0.4.5}/examples/quickstart/prodc.toml +0 -0
  20. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/__init__.py +0 -0
  21. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/adapters.py +0 -0
  22. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/agent.md +0 -0
  23. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/callbacks.py +0 -0
  24. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/__init__.py +0 -0
  25. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/common.py +0 -0
  26. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/serialize.py +0 -0
  27. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/config.py +0 -0
  28. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/dedup.py +0 -0
  29. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/guide.md +0 -0
  30. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/py.typed +0 -0
  31. {prodc-0.4.4 → prodc-0.4.5}/src/prodc/report_html.py +0 -0
  32. {prodc-0.4.4 → prodc-0.4.5}/tests/test_adapters.py +0 -0
  33. {prodc-0.4.4 → prodc-0.4.5}/tests/test_answer.py +0 -0
  34. {prodc-0.4.4 → prodc-0.4.5}/tests/test_callbacks.py +0 -0
  35. {prodc-0.4.4 → prodc-0.4.5}/tests/test_citation.py +0 -0
  36. {prodc-0.4.4 → prodc-0.4.5}/tests/test_claim_gating.py +0 -0
  37. {prodc-0.4.4 → prodc-0.4.5}/tests/test_cli.py +0 -0
  38. {prodc-0.4.4 → prodc-0.4.5}/tests/test_command_hook.py +0 -0
  39. {prodc-0.4.4 → prodc-0.4.5}/tests/test_contradicts.py +0 -0
  40. {prodc-0.4.4 → prodc-0.4.5}/tests/test_decisions.py +0 -0
  41. {prodc-0.4.4 → prodc-0.4.5}/tests/test_dedup.py +0 -0
  42. {prodc-0.4.4 → prodc-0.4.5}/tests/test_description.py +0 -0
  43. {prodc-0.4.4 → prodc-0.4.5}/tests/test_drafts.py +0 -0
  44. {prodc-0.4.4 → prodc-0.4.5}/tests/test_evalgate.py +0 -0
  45. {prodc-0.4.4 → prodc-0.4.5}/tests/test_external_adapter.py +0 -0
  46. {prodc-0.4.4 → prodc-0.4.5}/tests/test_gitlab_source.py +0 -0
  47. {prodc-0.4.4 → prodc-0.4.5}/tests/test_importance.py +0 -0
  48. {prodc-0.4.4 → prodc-0.4.5}/tests/test_loader.py +0 -0
  49. {prodc-0.4.4 → prodc-0.4.5}/tests/test_models.py +0 -0
  50. {prodc-0.4.4 → prodc-0.4.5}/tests/test_needref.py +0 -0
  51. {prodc-0.4.4 → prodc-0.4.5}/tests/test_relink.py +0 -0
  52. {prodc-0.4.4 → prodc-0.4.5}/tests/test_report.py +0 -0
  53. {prodc-0.4.4 → prodc-0.4.5}/tests/test_report_html.py +0 -0
  54. {prodc-0.4.4 → prodc-0.4.5}/tests/test_review.py +0 -0
  55. {prodc-0.4.4 → prodc-0.4.5}/tests/test_roles.py +0 -0
  56. {prodc-0.4.4 → prodc-0.4.5}/tests/test_source_role.py +0 -0
  57. {prodc-0.4.4 → prodc-0.4.5}/tests/test_sources.py +0 -0
  58. {prodc-0.4.4 → prodc-0.4.5}/tests/test_staleness.py +0 -0
  59. {prodc-0.4.4 → prodc-0.4.5}/tests/test_status.py +0 -0
  60. {prodc-0.4.4 → prodc-0.4.5}/tests/test_suggest.py +0 -0
  61. {prodc-0.4.4 → prodc-0.4.5}/tests/test_variants.py +0 -0
  62. {prodc-0.4.4 → prodc-0.4.5}/tests/test_who_variants.py +0 -0
  63. {prodc-0.4.4 → prodc-0.4.5}/tests/test_withdrawn.py +0 -0
  64. {prodc-0.4.4 → prodc-0.4.5}/tests/test_writer.py +0 -0
  65. {prodc-0.4.4 → prodc-0.4.5}/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.4
3
+ Version: 0.4.5
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.4"
3
+ version = "0.4.5"
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"
@@ -248,5 +248,5 @@ def gather(
248
248
  handoffs=handoffs(use_needs),
249
249
  recurrence=recurrence(use_needs),
250
250
  orphans=orphans(use_needs, cfg.adapters),
251
- principles=principle_report(list(model.needs), list(model.principles)),
251
+ principles=principle_report(list(model.needs), list(model.principles), cfg.adapters),
252
252
  )
@@ -188,6 +188,22 @@ def main(argv: list[str] | None = None) -> int:
188
188
  help="principle only: its scope — product (default), design, visual, engineering; "
189
189
  "must be declared in [principles].scopes",
190
190
  )
191
+ ad.add_argument(
192
+ "--implemented-in",
193
+ default=None,
194
+ metavar="LOCATOR",
195
+ dest="implemented_in",
196
+ help="principle only: a locator to the one place its values live (a pointer, not a copy)",
197
+ )
198
+ ad.add_argument(
199
+ "--proof",
200
+ action="append",
201
+ default=None,
202
+ dest="principle_proofs",
203
+ metavar="LOCATOR",
204
+ help="principle only: a guard-test locator proving the code obeys it (repeatable); "
205
+ "never a need: reference",
206
+ )
191
207
  ad.add_argument(
192
208
  "--needs",
193
209
  default=None,
@@ -28,7 +28,7 @@ from ..dedup import (
28
28
  who_variants,
29
29
  )
30
30
  from ..loader import LoaderError, load
31
- from ..model import Need, NeedResult, NeedState, board_needs
31
+ from ..model import Need, NeedResult, NeedState, PrincipleState, board_needs
32
32
  from ..report import render_markdown
33
33
  from ..report_html import render_html
34
34
  from ..status import (
@@ -229,14 +229,31 @@ def cmd_lint(args: argparse.Namespace) -> int:
229
229
  _annotate_draft_age_warnings(results, cfg)
230
230
  # The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
231
231
  # comes back up — the frame it was judged against has changed since. A warning, never a hard
232
- # error; prodc flags, the PM re-decides.
233
- for rep in principle_report(needs, list(model.principles)):
232
+ # error; prodc flags, the PM re-decides. The same loop grades each principle's guards (#89): a
233
+ # BROKEN guard/implemented_in is a hard error (a dangling locator), a VIOLATED guard warns (the
234
+ # code contradicts a stated principle — surfaced, but the PM owns the reconcile).
235
+ principle_errors: list[str] = []
236
+ principle_warnings: list[tuple[str, str]] = []
237
+ for rep in principle_report(needs, list(model.principles), cfg.adapters):
234
238
  for n in rep.stale:
235
239
  if n.id in by_id and n.withdrawn is not None:
236
240
  by_id[n.id].warnings.append(
237
241
  f"withdrawn per principle {rep.principle.id} on {n.withdrawn.date}, but "
238
242
  f"{rep.principle.id} was revised {rep.principle.revised_at} — re-review the decision"
239
243
  )
244
+ if rep.state is PrincipleState.BROKEN:
245
+ principle_errors.append(
246
+ f"{rep.principle.id}: BROKEN principle guard — a proof or implemented_in locator "
247
+ "resolves to nothing"
248
+ )
249
+ elif rep.state is PrincipleState.VIOLATED:
250
+ principle_warnings.append(
251
+ (
252
+ rep.principle.id,
253
+ "VIOLATED — a guard failed: the code contradicts this principle; reconcile the "
254
+ "code or revise the principle",
255
+ )
256
+ )
240
257
  # Unresolved contradiction: two needs oppose each other yet BOTH are still live — the product
241
258
  # vision has not decided between them. A warning on both sides (withdraw one out_of_scope citing
242
259
  # the principle that decides, or reconcile them).
@@ -249,10 +266,10 @@ def cmd_lint(args: argparse.Namespace) -> int:
249
266
  f"unresolved contradiction with {far.id} — both live, the vision has not "
250
267
  "decided yet (withdraw one out_of_scope citing the principle, or reconcile)"
251
268
  )
252
- errors = _lint_errors(results)
269
+ errors = _lint_errors(results) + principle_errors
253
270
  warnings = [
254
271
  (r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
255
- ]
272
+ ] + principle_warnings
256
273
  # Decision drift (keyed by the decision id, not a need): a decision left serving only
257
274
  # withdrawn/non-product needs is engineering with no live product reason; a decision decided
258
275
  # under a principle that was revised later comes back up (the same ripple a withdrawal gets).
@@ -558,6 +575,8 @@ def cmd_add(args: argparse.Namespace) -> int:
558
575
  file=args.file,
559
576
  revised_at=date.today().isoformat(),
560
577
  scope=args.scope,
578
+ implemented_in=args.implemented_in,
579
+ proofs=tuple(args.principle_proofs or ()),
561
580
  sources=sources,
562
581
  expect_sha=args.expect_sha,
563
582
  dry_run=args.dry_run,
@@ -18,6 +18,7 @@ from ..model import (
18
18
  Need,
19
19
  NeedResult,
20
20
  NeedState,
21
+ PrincipleState,
21
22
  Release,
22
23
  Role,
23
24
  ears_shape,
@@ -290,15 +291,36 @@ def render_principles(reports: list[PrincipleReport]) -> str:
290
291
  a = rep.principle
291
292
  rev = f"revised {a.revised_at}" if a.revised_at else "undated"
292
293
  scope = "" if a.scope == "product" else f"[{a.scope}] "
293
- out.append(f" {a.id:<6} {scope}{rev} {a.text}")
294
+ flag = "⚠ " if rep.state in (PrincipleState.VIOLATED, PrincipleState.BROKEN) else ""
295
+ out.append(f" {a.id:<6} {flag}{rep.state.value.upper():<9} {scope}{rev} {a.text}")
296
+ if a.implemented_in:
297
+ out.append(f" implemented in: {a.implemented_in}")
294
298
  if rep.declined:
295
299
  out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
296
300
  if rep.stale:
297
301
  ids = ", ".join(n.id for n in rep.stale)
298
302
  out.append(f" ⚠ decided before the latest revision: {ids} — re-review")
303
+ tally = _principle_tally(reports)
304
+ if tally:
305
+ out.append(f" — {tally}")
299
306
  return "\n".join(out)
300
307
 
301
308
 
309
+ def _principle_tally(reports: list[PrincipleReport]) -> str:
310
+ """A per-scope ENFORCED/STATED/… count line, scopes and states in declared enum order (#89)."""
311
+ by_scope: dict[str, dict[PrincipleState, int]] = {}
312
+ for rep in reports:
313
+ counts = by_scope.setdefault(rep.principle.scope, {})
314
+ counts[rep.state] = counts.get(rep.state, 0) + 1
315
+ parts: list[str] = []
316
+ for scope, counts in by_scope.items():
317
+ inner = ", ".join(
318
+ f"{counts[s]} {s.value}" for s in PrincipleState if counts.get(s)
319
+ )
320
+ parts.append(f"{scope}: {inner}")
321
+ return " · ".join(parts)
322
+
323
+
302
324
  def render_contradictions(contras: list[Contradiction]) -> str:
303
325
  """Unresolved contradictions on the board: two needs in tension the vision has not decided."""
304
326
  out = ["\ncontradictions (two needs in tension — the vision has not decided):"]
@@ -344,10 +344,21 @@ def _role(raw: object, where: str) -> Role:
344
344
 
345
345
 
346
346
  def _principle(raw: object, where: str) -> Principle:
347
- """Parse one ``principles:`` entry — a product commitment with its provenance."""
347
+ """Parse one ``principles:`` entry — a product commitment with its provenance and guards."""
348
348
  tbl = _as_mapping(raw, where)
349
+ pid = _as_str(tbl.get("id"), f"{where}.id")
350
+ proofs = tuple(
351
+ _as_str(p, f"{where}.proofs[{i}]")
352
+ for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
353
+ )
354
+ for p in proofs:
355
+ if p.split(":", 1)[0] == "need":
356
+ raise LoaderError(
357
+ f"principle {pid!r}: proof {p!r} is a need reference — a principle guards the "
358
+ "code, it does not wait on a need to settle (that inverts the OST)"
359
+ )
349
360
  return Principle(
350
- id=_as_str(tbl.get("id"), f"{where}.id"),
361
+ id=pid,
351
362
  text=_as_str(tbl.get("text"), f"{where}.text"),
352
363
  scope=_opt_str(tbl.get("scope"), f"{where}.scope") or "product",
353
364
  revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
@@ -355,6 +366,8 @@ def _principle(raw: object, where: str) -> Principle:
355
366
  _source(s, f"{where}.sources[{i}]")
356
367
  for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
357
368
  ),
369
+ implemented_in=_opt_str(tbl.get("implemented_in"), f"{where}.implemented_in"),
370
+ proofs=proofs,
358
371
  )
359
372
 
360
373
 
@@ -48,6 +48,23 @@ class NeedState(StrEnum):
48
48
  UNRESOLVED = "unresolved" # a proof's source could not be read (e.g. sibling repo absent)
49
49
 
50
50
 
51
+ class PrincipleState(StrEnum):
52
+ """Whether a principle's proofs show the code still obeys it (#89).
53
+
54
+ A principle proof is a *guard against regression*, the mirror of a need proof's *progress toward*
55
+ an outcome: a failing need proof is "not yet", but a failing principle guard is "the code
56
+ contradicts this stance right now" — exactly the tokens.css event where a reversed rule's old
57
+ wording lived on for months. So a failing guard is VIOLATED, never merely PENDING, and the board
58
+ cannot read a contradicted principle as green.
59
+ """
60
+
61
+ ENFORCED = "enforced" # every proof resolved green — the code demonstrably obeys it
62
+ STATED = "stated" # no active guard (no proofs, or only declared-pending ones) — honest, allowed
63
+ VIOLATED = "violated" # a guard ran and failed — the code contradicts the stance now
64
+ BROKEN = "broken" # a proof or implemented_in locator resolves to nothing — lint fails
65
+ UNRESOLVED = "unresolved" # a proof's source could not be read (e.g. sibling repo absent)
66
+
67
+
51
68
  class Answer(StrEnum):
52
69
  """Which way a settled question resolved — the research board's direction axis.
53
70
 
@@ -169,6 +186,13 @@ class Principle:
169
186
  and code comments, where a Decision or an ``out_of_scope`` withdrawal cannot cite them. The scope
170
187
  must be one declared in ``[principles].scopes`` (default ``["product"]``); an undeclared scope is a
171
188
  load error the author owns.
189
+
190
+ ``proofs`` tie the principle to the guard tests that prove the code still obeys it, resolved by
191
+ the same adapters as a need's proofs, and ``implemented_in`` points at the one place the values
192
+ live (e.g. a tokens file) — a pointer, not a copy, so the hex/font values stay in code. Together
193
+ they give the principle a :class:`PrincipleState` (ENFORCED / STATED / VIOLATED / BROKEN); a
194
+ principle may not carry a ``need:`` proof — it guards the code, it does not wait on a need to
195
+ settle — and it still never enters coverage or the plan.
172
196
  """
173
197
 
174
198
  id: str
@@ -176,6 +200,8 @@ class Principle:
176
200
  scope: str = "product" # product/design/visual/engineering — declared in [principles].scopes (#88)
177
201
  revised_at: str | None = None # ISO date the principle was last stated or revised
178
202
  sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
203
+ implemented_in: str | None = None # locator to the ONE place the values live; a pointer, not a copy (#89)
204
+ proofs: tuple[str, ...] = () # guard-test locators, resolved by the same adapters as needs (#89)
179
205
 
180
206
 
181
207
  @dataclass(frozen=True)
@@ -14,7 +14,7 @@ from datetime import date
14
14
  from .board import gather
15
15
  from .config import Config
16
16
  from .model import Model, NeedResult, NeedState
17
- from .status import active_decisions, group_state, principle_report, state_priority
17
+ from .status import active_decisions, group_state, state_priority
18
18
 
19
19
  _LABEL = {
20
20
  NeedState.DELIVERED: "DELIVERED",
@@ -156,15 +156,15 @@ def render_markdown(
156
156
  out += [
157
157
  "## Principles (the vision — held as given)",
158
158
  "",
159
- "| principle | scope | stated | needs declined under it |",
160
- "|---|---|---|---|",
159
+ "| principle | scope | state | stated | needs declined under it |",
160
+ "|---|---|---|---|---|",
161
161
  ]
162
- for rep in principle_report(list(model.needs), list(model.principles)):
162
+ for rep in board.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.scope} "
167
+ f"| {rep.principle.id}: {text} | {rep.principle.scope} | {rep.state.value.upper()} "
168
168
  f"| {rep.principle.revised_at or '—'} | {declined}{stale} |"
169
169
  )
170
170
  out.append("")
@@ -23,6 +23,7 @@ from .model import (
23
23
  NeedResult,
24
24
  NeedState,
25
25
  Principle,
26
+ PrincipleState,
26
27
  ProofState,
27
28
  Release,
28
29
  Role,
@@ -658,13 +659,48 @@ def role_report(
658
659
  return coverage, unroled
659
660
 
660
661
 
662
+ def principle_state(principle: Principle, adapters: dict[str, Adapter]) -> PrincipleState:
663
+ """Resolve a principle's guards into its enforced/stated/violated/broken state (#89).
664
+
665
+ A principle proof is a guard against regression, so its ``ProofState`` reads differently from a
666
+ need's (:class:`~prodc.model.PrincipleState` says why). Using :attr:`ProofResult.declared` to tell
667
+ a declared-but-inactive guard from one that actually ran and failed:
668
+
669
+ - ``implemented_in`` set but not resolving, or any proof BROKEN → **BROKEN** (lint fails).
670
+ - any proof UNRESOLVED → **UNRESOLVED** (a source could not be read).
671
+ - a proof PENDING with ``declared=False`` (a results artifact shows it failing) → **VIOLATED**.
672
+ - no proofs, or only DELIVERED-or-declared-pending guards and not all green → **STATED** (honest).
673
+ - every proof DELIVERED → **ENFORCED**.
674
+
675
+ ``implemented_in`` is a pointer, not evidence: it can only make a principle BROKEN (a dangling
676
+ locator), never ENFORCED.
677
+ """
678
+ impl_broken = (
679
+ principle.implemented_in is not None
680
+ and _resolve_proof(principle.implemented_in, adapters).state is ProofState.BROKEN
681
+ )
682
+ results = [_resolve_proof(loc, adapters) for loc in principle.proofs]
683
+ if not principle.proofs:
684
+ return PrincipleState.BROKEN if impl_broken else PrincipleState.STATED
685
+ if impl_broken or any(r.state is ProofState.BROKEN for r in results):
686
+ return PrincipleState.BROKEN
687
+ if any(r.state is ProofState.UNRESOLVED for r in results):
688
+ return PrincipleState.UNRESOLVED
689
+ if any(r.state is ProofState.PENDING and not r.declared for r in results):
690
+ return PrincipleState.VIOLATED # a guard ran and failed — the code contradicts the stance
691
+ if all(r.state is ProofState.DELIVERED for r in results):
692
+ return PrincipleState.ENFORCED
693
+ return PrincipleState.STATED # some declared-pending guards, none failing — honestly not yet active
694
+
695
+
661
696
  @dataclass(frozen=True)
662
697
  class PrincipleReport:
663
- """One principle and the decisions that rest on it — the ripple, made visible."""
698
+ """One principle, its enforcement state, and the decisions that rest on it — the ripple made visible."""
664
699
 
665
700
  principle: Principle
666
701
  declined: tuple[Need, ...] # needs withdrawn citing this principle
667
702
  stale: tuple[Need, ...] # of those, decided before the principle's latest revision — re-review
703
+ state: PrincipleState = PrincipleState.STATED # enforced/stated/violated/broken (#89)
668
704
 
669
705
 
670
706
  def _decided_before(decided: str, revised: str | None) -> bool:
@@ -681,13 +717,19 @@ def _decided_before(decided: str, revised: str | None) -> bool:
681
717
  return False
682
718
 
683
719
 
684
- def principle_report(needs: list[Need], principles: list[Principle]) -> list[PrincipleReport]:
685
- """Each declared principle with the needs withdrawn under it, and which of those are stale.
720
+ def principle_report(
721
+ needs: list[Need],
722
+ principles: list[Principle],
723
+ adapters: dict[str, Adapter] | None = None,
724
+ ) -> list[PrincipleReport]:
725
+ """Each declared principle with its enforcement state, the needs withdrawn under it, and the stale ones.
686
726
 
687
727
  Reads the *whole* need list, withdrawn needs included — a declined need is off the board, so
688
728
  the board-filtered list would hide exactly what this reports. A decision is stale when the
689
729
  withdrawal is dated before the principle's ``revised_at``: the frame it was judged against has
690
730
  changed since, so it comes back up for re-review. Records-only — prodc flags, the PM re-decides.
731
+ When ``adapters`` are given, each principle also carries its :func:`principle_state` (#89); without
732
+ them the state defaults to STATED (a caller that has no proof adapters, e.g. a plain ripple view).
691
733
  """
692
734
  by_principle: dict[str, list[Need]] = {a.id: [] for a in principles}
693
735
  for n in needs:
@@ -701,7 +743,8 @@ def principle_report(needs: list[Need], principles: list[Principle]) -> list[Pri
701
743
  for n in declined
702
744
  if n.withdrawn is not None and _decided_before(n.withdrawn.date, a.revised_at)
703
745
  ]
704
- out.append(PrincipleReport(a, tuple(declined), tuple(stale)))
746
+ state = principle_state(a, adapters) if adapters is not None else PrincipleState.STATED
747
+ out.append(PrincipleReport(a, tuple(declined), tuple(stale), state))
705
748
  return out
706
749
 
707
750
 
@@ -915,6 +915,8 @@ def add_principle(
915
915
  file: str,
916
916
  revised_at: str,
917
917
  scope: str = "product",
918
+ implemented_in: str | None = None,
919
+ proofs: tuple[str, ...] = (),
918
920
  sources: tuple[Source, ...] = (),
919
921
  expect_sha: str | None = None,
920
922
  dry_run: bool,
@@ -948,6 +950,10 @@ def add_principle(
948
950
  if scope != "product":
949
951
  entry["scope"] = scope # written only when non-default, to keep the common case clean
950
952
  entry["revised_at"] = revised_at
953
+ if implemented_in is not None:
954
+ entry["implemented_in"] = implemented_in
955
+ if proofs:
956
+ entry["proofs"] = list(proofs)
951
957
  if sources:
952
958
  entry["sources"] = [_source_dict(s) for s in sources]
953
959
  principles.append(entry)
@@ -7,15 +7,32 @@ it rests on; revising that principle later flags the decision for re-review (the
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ from dataclasses import dataclass
10
11
  from datetime import date
11
12
  from pathlib import Path
12
13
 
13
14
  import pytest
14
15
 
16
+ from prodc.adapters import Adapter, ProofResult
15
17
  from prodc.config import Config
16
18
  from prodc.loader import LoaderError, load
17
- from prodc.model import Principle, Source, SourceKind
18
- from prodc.status import principle_report
19
+ from prodc.model import Principle, PrincipleState, ProofState, Source, SourceKind
20
+ from prodc.status import principle_report, principle_state
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class _Stub:
25
+ """Resolves any payload to a fixed ProofResult — a per-adapter test double."""
26
+
27
+ result: ProofResult
28
+
29
+ def resolve(self, payload: str) -> ProofResult:
30
+ _ = payload
31
+ return self.result
32
+
33
+
34
+ def _adapter(state: ProofState, *, declared: bool) -> dict[str, Adapter]:
35
+ return {"g": _Stub(ProofResult(state, declared=declared, detail="stub"))}
19
36
 
20
37
  _TOML = """
21
38
  [prodc]
@@ -179,6 +196,64 @@ def test_add_principle_writes_scope(tmp_path: Path) -> None:
179
196
  assert next(p for p in model.principles if p.id == "P-9").scope == "visual"
180
197
 
181
198
 
199
+ # --- enforced / stated / violated / broken (#89) ----------------------------
200
+
201
+
202
+ def _p(**kw: object) -> Principle:
203
+ return Principle(id="P-1", text="colour is load-bearing", **kw) # type: ignore[arg-type]
204
+
205
+
206
+ def test_no_proofs_is_stated() -> None:
207
+ # a principle with no guards is honestly STATED — allowed, never a gate failure.
208
+ assert principle_state(_p(), _adapter(ProofState.DELIVERED, declared=True)) is PrincipleState.STATED
209
+
210
+
211
+ def test_all_proofs_delivered_is_enforced() -> None:
212
+ p = _p(proofs=("g:noPurple",))
213
+ assert principle_state(p, _adapter(ProofState.DELIVERED, declared=True)) is PrincipleState.ENFORCED
214
+
215
+
216
+ def test_failing_guard_with_results_is_violated() -> None:
217
+ # the trap: a guard that ran and FAILED (results artifact present, not declared) means the code
218
+ # contradicts the principle NOW — VIOLATED, never a mere STATED/PENDING. "The board cannot lie."
219
+ p = _p(proofs=("g:noPurple",))
220
+ assert principle_state(p, _adapter(ProofState.PENDING, declared=False)) is PrincipleState.VIOLATED
221
+
222
+
223
+ def test_declared_pending_guard_is_stated_not_violated() -> None:
224
+ # a guard that is only declared (e.g. @pending, no results) is not yet active — STATED, not VIOLATED.
225
+ p = _p(proofs=("g:noPurple",))
226
+ assert principle_state(p, _adapter(ProofState.PENDING, declared=True)) is PrincipleState.STATED
227
+
228
+
229
+ def test_broken_proof_is_broken() -> None:
230
+ p = _p(proofs=("g:noPurple",))
231
+ assert principle_state(p, _adapter(ProofState.BROKEN, declared=False)) is PrincipleState.BROKEN
232
+
233
+
234
+ def test_dangling_implemented_in_is_broken_even_without_proofs() -> None:
235
+ p = _p(implemented_in="g:tokens.css")
236
+ assert principle_state(p, _adapter(ProofState.BROKEN, declared=False)) is PrincipleState.BROKEN
237
+
238
+
239
+ def test_need_proof_on_principle_rejected(tmp_path: Path) -> None:
240
+ needs = "principles:\n - id: P-1\n text: a principle\n proofs:\n - 'need:N-1@yes'\n"
241
+ with pytest.raises(LoaderError, match="guards the code, it does not wait on a need"):
242
+ load(Config.load(_project(tmp_path, needs=needs)))
243
+
244
+
245
+ def test_lint_hard_fails_broken_principle(tmp_path: Path) -> None:
246
+ import argparse
247
+
248
+ from prodc.cli.commands import cmd_lint
249
+
250
+ # a principle whose only proof is an unresolvable locator (no 'ghost' adapter) is BROKEN.
251
+ needs = "principles:\n - id: P-1\n text: a principle\n proofs:\n - 'ghost:x'\n"
252
+ cfg_path = _project(tmp_path, needs=needs)
253
+ args = argparse.Namespace(config=str(cfg_path), strict=False, json=False)
254
+ assert cmd_lint(args) == 1
255
+
256
+
182
257
  # --- principle_report (declined / stale) ------------------------------------
183
258
 
184
259
 
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