prodc 0.4.4__tar.gz → 0.4.6__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.6}/PKG-INFO +1 -1
  2. {prodc-0.4.4 → prodc-0.4.6}/pyproject.toml +1 -1
  3. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/board.py +3 -1
  4. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/app.py +22 -0
  5. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/commands.py +36 -5
  6. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/render.py +25 -1
  7. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/dedup.py +32 -0
  8. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/loader.py +20 -2
  9. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/model.py +27 -0
  10. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/report.py +5 -5
  11. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/status.py +47 -4
  12. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/writer.py +7 -1
  13. {prodc-0.4.4 → prodc-0.4.6}/tests/test_dedup.py +18 -1
  14. {prodc-0.4.4 → prodc-0.4.6}/tests/test_loader.py +10 -0
  15. {prodc-0.4.4 → prodc-0.4.6}/tests/test_principles.py +77 -2
  16. {prodc-0.4.4 → prodc-0.4.6}/tests/test_writer.py +8 -0
  17. {prodc-0.4.4 → prodc-0.4.6}/.gitignore +0 -0
  18. {prodc-0.4.4 → prodc-0.4.6}/CHANGELOG.md +0 -0
  19. {prodc-0.4.4 → prodc-0.4.6}/LICENSE +0 -0
  20. {prodc-0.4.4 → prodc-0.4.6}/README.md +0 -0
  21. {prodc-0.4.4 → prodc-0.4.6}/examples/quickstart/features/orders.feature +0 -0
  22. {prodc-0.4.4 → prodc-0.4.6}/examples/quickstart/needs/jordan.yaml +0 -0
  23. {prodc-0.4.4 → prodc-0.4.6}/examples/quickstart/prodc.toml +0 -0
  24. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/__init__.py +0 -0
  25. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/adapters.py +0 -0
  26. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/agent.md +0 -0
  27. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/callbacks.py +0 -0
  28. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/__init__.py +0 -0
  29. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/common.py +0 -0
  30. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/serialize.py +0 -0
  31. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/config.py +0 -0
  32. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/guide.md +0 -0
  33. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/py.typed +0 -0
  34. {prodc-0.4.4 → prodc-0.4.6}/src/prodc/report_html.py +0 -0
  35. {prodc-0.4.4 → prodc-0.4.6}/tests/test_adapters.py +0 -0
  36. {prodc-0.4.4 → prodc-0.4.6}/tests/test_answer.py +0 -0
  37. {prodc-0.4.4 → prodc-0.4.6}/tests/test_callbacks.py +0 -0
  38. {prodc-0.4.4 → prodc-0.4.6}/tests/test_citation.py +0 -0
  39. {prodc-0.4.4 → prodc-0.4.6}/tests/test_claim_gating.py +0 -0
  40. {prodc-0.4.4 → prodc-0.4.6}/tests/test_cli.py +0 -0
  41. {prodc-0.4.4 → prodc-0.4.6}/tests/test_command_hook.py +0 -0
  42. {prodc-0.4.4 → prodc-0.4.6}/tests/test_contradicts.py +0 -0
  43. {prodc-0.4.4 → prodc-0.4.6}/tests/test_decisions.py +0 -0
  44. {prodc-0.4.4 → prodc-0.4.6}/tests/test_description.py +0 -0
  45. {prodc-0.4.4 → prodc-0.4.6}/tests/test_drafts.py +0 -0
  46. {prodc-0.4.4 → prodc-0.4.6}/tests/test_evalgate.py +0 -0
  47. {prodc-0.4.4 → prodc-0.4.6}/tests/test_external_adapter.py +0 -0
  48. {prodc-0.4.4 → prodc-0.4.6}/tests/test_gitlab_source.py +0 -0
  49. {prodc-0.4.4 → prodc-0.4.6}/tests/test_importance.py +0 -0
  50. {prodc-0.4.4 → prodc-0.4.6}/tests/test_models.py +0 -0
  51. {prodc-0.4.4 → prodc-0.4.6}/tests/test_needref.py +0 -0
  52. {prodc-0.4.4 → prodc-0.4.6}/tests/test_relink.py +0 -0
  53. {prodc-0.4.4 → prodc-0.4.6}/tests/test_report.py +0 -0
  54. {prodc-0.4.4 → prodc-0.4.6}/tests/test_report_html.py +0 -0
  55. {prodc-0.4.4 → prodc-0.4.6}/tests/test_review.py +0 -0
  56. {prodc-0.4.4 → prodc-0.4.6}/tests/test_roles.py +0 -0
  57. {prodc-0.4.4 → prodc-0.4.6}/tests/test_source_role.py +0 -0
  58. {prodc-0.4.4 → prodc-0.4.6}/tests/test_sources.py +0 -0
  59. {prodc-0.4.4 → prodc-0.4.6}/tests/test_staleness.py +0 -0
  60. {prodc-0.4.4 → prodc-0.4.6}/tests/test_status.py +0 -0
  61. {prodc-0.4.4 → prodc-0.4.6}/tests/test_suggest.py +0 -0
  62. {prodc-0.4.4 → prodc-0.4.6}/tests/test_variants.py +0 -0
  63. {prodc-0.4.4 → prodc-0.4.6}/tests/test_who_variants.py +0 -0
  64. {prodc-0.4.4 → prodc-0.4.6}/tests/test_withdrawn.py +0 -0
  65. {prodc-0.4.4 → prodc-0.4.6}/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.6
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.6"
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"
@@ -115,6 +115,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
115
115
  ),
116
116
  "proofs": list(need.proofs),
117
117
  "contradicts": list(need.contradicts),
118
+ "tags": list(need.tags),
118
119
  "detail": list(r.detail),
119
120
  "warnings": list(r.warnings),
120
121
  "attestation": (
@@ -157,6 +158,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
157
158
  "product": need.product,
158
159
  "draft": need.draft,
159
160
  "contradicts": list(need.contradicts),
161
+ "tags": list(need.tags),
160
162
  "answer": need.answer.value if need.answer else None,
161
163
  "withdrawn": (
162
164
  {
@@ -248,5 +250,5 @@ def gather(
248
250
  handoffs=handoffs(use_needs),
249
251
  recurrence=recurrence(use_needs),
250
252
  orphans=orphans(use_needs, cfg.adapters),
251
- principles=principle_report(list(model.needs), list(model.principles)),
253
+ principles=principle_report(list(model.needs), list(model.principles), cfg.adapters),
252
254
  )
@@ -67,6 +67,12 @@ def main(argv: list[str] | None = None) -> int:
67
67
  metavar="ID",
68
68
  help="scope the board + coverage to the needs serving one goal",
69
69
  )
70
+ st.add_argument(
71
+ "--tag",
72
+ default=None,
73
+ metavar="TAG",
74
+ help="scope the board + coverage to the needs carrying one tag (#80)",
75
+ )
70
76
  st.add_argument(
71
77
  "--columns",
72
78
  default=None,
@@ -188,6 +194,22 @@ def main(argv: list[str] | None = None) -> int:
188
194
  help="principle only: its scope — product (default), design, visual, engineering; "
189
195
  "must be declared in [principles].scopes",
190
196
  )
197
+ ad.add_argument(
198
+ "--implemented-in",
199
+ default=None,
200
+ metavar="LOCATOR",
201
+ dest="implemented_in",
202
+ help="principle only: a locator to the one place its values live (a pointer, not a copy)",
203
+ )
204
+ ad.add_argument(
205
+ "--proof",
206
+ action="append",
207
+ default=None,
208
+ dest="principle_proofs",
209
+ metavar="LOCATOR",
210
+ help="principle only: a guard-test locator proving the code obeys it (repeatable); "
211
+ "never a need: reference",
212
+ )
191
213
  ad.add_argument(
192
214
  "--needs",
193
215
  default=None,
@@ -24,11 +24,12 @@ from ..dedup import (
24
24
  VariantGroup,
25
25
  duplicate_pairs,
26
26
  matches_for,
27
+ tag_warnings,
27
28
  variant_suggestions,
28
29
  who_variants,
29
30
  )
30
31
  from ..loader import LoaderError, load
31
- from ..model import Need, NeedResult, NeedState, board_needs
32
+ from ..model import Need, NeedResult, NeedState, PrincipleState, board_needs
32
33
  from ..report import render_markdown
33
34
  from ..report_html import render_html
34
35
  from ..status import (
@@ -229,14 +230,31 @@ def cmd_lint(args: argparse.Namespace) -> int:
229
230
  _annotate_draft_age_warnings(results, cfg)
230
231
  # The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
231
232
  # 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)):
233
+ # error; prodc flags, the PM re-decides. The same loop grades each principle's guards (#89): a
234
+ # BROKEN guard/implemented_in is a hard error (a dangling locator), a VIOLATED guard warns (the
235
+ # code contradicts a stated principle — surfaced, but the PM owns the reconcile).
236
+ principle_errors: list[str] = []
237
+ principle_warnings: list[tuple[str, str]] = []
238
+ for rep in principle_report(needs, list(model.principles), cfg.adapters):
234
239
  for n in rep.stale:
235
240
  if n.id in by_id and n.withdrawn is not None:
236
241
  by_id[n.id].warnings.append(
237
242
  f"withdrawn per principle {rep.principle.id} on {n.withdrawn.date}, but "
238
243
  f"{rep.principle.id} was revised {rep.principle.revised_at} — re-review the decision"
239
244
  )
245
+ if rep.state is PrincipleState.BROKEN:
246
+ principle_errors.append(
247
+ f"{rep.principle.id}: BROKEN principle guard — a proof or implemented_in locator "
248
+ "resolves to nothing"
249
+ )
250
+ elif rep.state is PrincipleState.VIOLATED:
251
+ principle_warnings.append(
252
+ (
253
+ rep.principle.id,
254
+ "VIOLATED — a guard failed: the code contradicts this principle; reconcile the "
255
+ "code or revise the principle",
256
+ )
257
+ )
240
258
  # Unresolved contradiction: two needs oppose each other yet BOTH are still live — the product
241
259
  # vision has not decided between them. A warning on both sides (withdraw one out_of_scope citing
242
260
  # the principle that decides, or reconcile them).
@@ -249,10 +267,10 @@ def cmd_lint(args: argparse.Namespace) -> int:
249
267
  f"unresolved contradiction with {far.id} — both live, the vision has not "
250
268
  "decided yet (withdraw one out_of_scope citing the principle, or reconcile)"
251
269
  )
252
- errors = _lint_errors(results)
270
+ errors = _lint_errors(results) + principle_errors
253
271
  warnings = [
254
272
  (r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
255
- ]
273
+ ] + principle_warnings
256
274
  # Decision drift (keyed by the decision id, not a need): a decision left serving only
257
275
  # withdrawn/non-product needs is engineering with no live product reason; a decision decided
258
276
  # under a principle that was revised later comes back up (the same ripple a withdrawal gets).
@@ -271,6 +289,15 @@ def cmd_lint(args: argparse.Namespace) -> int:
271
289
  f"decided {d.decided_at} under principle {pid}, revised later — re-review this solution",
272
290
  )
273
291
  )
292
+ # Tag hygiene (#80): the flat tag space is undeclared, so flag singletons, near-duplicate
293
+ # spellings, and a tag standing in for a declared goal/role/release/client (structure hiding).
294
+ structural_ids = (
295
+ {g.id for g in model.goals}
296
+ | {p.id for p in model.roles}
297
+ | {r.id for r in model.releases}
298
+ | {n.client for n in needs if n.client is not None}
299
+ )
300
+ warnings += tag_warnings(needs, structural_ids, cfg.duplicate_threshold)
274
301
  if args.json:
275
302
  print(
276
303
  json.dumps(
@@ -314,6 +341,8 @@ def cmd_status(args: argparse.Namespace) -> int:
314
341
  needs = applicable_needs(needs, args.client)
315
342
  if args.goal:
316
343
  needs = [n for n in needs if n.goal == args.goal]
344
+ if args.tag:
345
+ needs = [n for n in needs if args.tag in n.tags]
317
346
  if args.stale is not None:
318
347
  today = date.today()
319
348
  needs = [
@@ -558,6 +587,8 @@ def cmd_add(args: argparse.Namespace) -> int:
558
587
  file=args.file,
559
588
  revised_at=date.today().isoformat(),
560
589
  scope=args.scope,
590
+ implemented_in=args.implemented_in,
591
+ proofs=tuple(args.principle_proofs or ()),
561
592
  sources=sources,
562
593
  expect_sha=args.expect_sha,
563
594
  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):"]
@@ -400,6 +422,8 @@ def render_show(
400
422
  lines.append(f" children: {rendered}")
401
423
  if need.contradicts:
402
424
  lines.append(f" contradicts: {', '.join(need.contradicts)}")
425
+ if need.tags:
426
+ lines.append(f" tags: {', '.join(need.tags)}")
403
427
  if need.seam:
404
428
  lines.append(f" seam: {', '.join(need.seam)}")
405
429
  if need.proofs:
@@ -237,3 +237,35 @@ def who_variants(needs: list[Need], threshold: float) -> list[WhoVariant]:
237
237
  out.append(WhoVariant(client, tuple(sorted(members)), tuple(ids)))
238
238
  out.sort(key=lambda v: (v.client or "", v.whos))
239
239
  return out
240
+
241
+
242
+ def tag_warnings(
243
+ needs: list[Need], structural_ids: set[str], threshold: float
244
+ ) -> list[tuple[str, str]]:
245
+ """Keep the flat tag space from rotting (#80): singletons, near-dup spellings, hidden structure.
246
+
247
+ Tags are undeclared and many-to-many, so nothing stops a typo (`search` vs `searching`) or a
248
+ one-off from accumulating. Three cheap hygiene signals, each a warning keyed by the tag:
249
+ a tag equal to a declared goal/role/release/client id ("structure hiding in a tag" — use the
250
+ first-class field instead), a singleton (one need only — a likely typo or a tag not worth it),
251
+ and a near-duplicate spelling of another tag (same :func:`similarity` the dedup checks use).
252
+ """
253
+ counts: dict[str, int] = {}
254
+ for n in needs:
255
+ for t in n.tags:
256
+ counts[t] = counts.get(t, 0) + 1
257
+ out: list[tuple[str, str]] = []
258
+ tags = sorted(counts)
259
+ for t in tags:
260
+ if t in structural_ids:
261
+ out.append(
262
+ (t, "tag equals a declared goal/role/release/client id — use that field, not a tag")
263
+ )
264
+ elif counts[t] == 1:
265
+ out.append((t, "tag used on only one need — a singleton (likely a typo or not worth a tag)"))
266
+ for i in range(len(tags)):
267
+ for j in range(i + 1, len(tags)):
268
+ a, b = tags[i], tags[j]
269
+ if _normalise(a) != _normalise(b) and similarity(a, b) >= threshold:
270
+ out.append((a, f"tag may be a near-duplicate spelling of {b!r}"))
271
+ return out
@@ -278,6 +278,10 @@ def _need(
278
278
  _as_str(c, f"{where}.contradicts[{i}]")
279
279
  for i, c in enumerate(_as_list(tbl.get("contradicts"), f"{where}.contradicts"))
280
280
  )
281
+ tags = tuple(
282
+ _as_str(t, f"{where}.tags[{i}]")
283
+ for i, t in enumerate(_as_list(tbl.get("tags"), f"{where}.tags"))
284
+ )
281
285
  if children and proofs:
282
286
  raise LoaderError(
283
287
  f"{where} ({nid}): a flow (need with children) proves its steps via the "
@@ -308,6 +312,7 @@ def _need(
308
312
  not_duplicate_of=not_duplicate_of,
309
313
  not_variant_of=not_variant_of,
310
314
  contradicts=contradicts,
315
+ tags=tags,
311
316
  importance_set_at=importance_set_at,
312
317
  draft=draft,
313
318
  draft_added=draft_added,
@@ -344,10 +349,21 @@ def _role(raw: object, where: str) -> Role:
344
349
 
345
350
 
346
351
  def _principle(raw: object, where: str) -> Principle:
347
- """Parse one ``principles:`` entry — a product commitment with its provenance."""
352
+ """Parse one ``principles:`` entry — a product commitment with its provenance and guards."""
348
353
  tbl = _as_mapping(raw, where)
354
+ pid = _as_str(tbl.get("id"), f"{where}.id")
355
+ proofs = tuple(
356
+ _as_str(p, f"{where}.proofs[{i}]")
357
+ for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
358
+ )
359
+ for p in proofs:
360
+ if p.split(":", 1)[0] == "need":
361
+ raise LoaderError(
362
+ f"principle {pid!r}: proof {p!r} is a need reference — a principle guards the "
363
+ "code, it does not wait on a need to settle (that inverts the OST)"
364
+ )
349
365
  return Principle(
350
- id=_as_str(tbl.get("id"), f"{where}.id"),
366
+ id=pid,
351
367
  text=_as_str(tbl.get("text"), f"{where}.text"),
352
368
  scope=_opt_str(tbl.get("scope"), f"{where}.scope") or "product",
353
369
  revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
@@ -355,6 +371,8 @@ def _principle(raw: object, where: str) -> Principle:
355
371
  _source(s, f"{where}.sources[{i}]")
356
372
  for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
357
373
  ),
374
+ implemented_in=_opt_str(tbl.get("implemented_in"), f"{where}.implemented_in"),
375
+ proofs=proofs,
358
376
  )
359
377
 
360
378
 
@@ -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)
@@ -324,6 +350,7 @@ class Need:
324
350
  contradicts: tuple[
325
351
  str, ...
326
352
  ] = () # ids of needs this one opposes; symmetric, a principle decides
353
+ tags: tuple[str, ...] = () # flat, many-to-many, stateless labels — a filter axis, not a group (#80)
327
354
 
328
355
  @property
329
356
  def is_variant(self) -> bool:
@@ -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
 
@@ -46,7 +46,7 @@ _SCALAR_FIELDS = frozenset(
46
46
  "draft",
47
47
  }
48
48
  )
49
- _LIST_FIELDS = frozenset({"proofs", "children", "contradicts"})
49
+ _LIST_FIELDS = frozenset({"proofs", "children", "contradicts", "tags"})
50
50
 
51
51
  # Source-kind aliases accepted on write (old names → the primary/secondary/assumption ladder),
52
52
  # normalised so new files use the current vocabulary even when an author types the old kind.
@@ -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)
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  import pytest
9
9
 
10
10
  from prodc.config import Config, ConfigError
11
- from prodc.dedup import duplicate_pairs, matches_for, similarity
11
+ from prodc.dedup import duplicate_pairs, matches_for, similarity, tag_warnings
12
12
  from prodc.model import Need
13
13
 
14
14
 
@@ -228,3 +228,20 @@ def test_score_prune_matches_brute_force() -> None:
228
228
  if s >= threshold:
229
229
  brute[frozenset((needs[i].id, needs[j].id))] = round(s, 10)
230
230
  assert got == brute, f"mismatch at threshold {threshold}"
231
+
232
+
233
+ def test_tag_warnings_singleton_nearspelling_and_structure() -> None:
234
+ needs = [
235
+ _n("N-1", "a", tags=("search", "searching", "onboarding")),
236
+ _n("N-2", "b", tags=("search", "onboarding")),
237
+ _n("N-3", "c", tags=("search", "G-1")), # G-1 is a declared goal id
238
+ ]
239
+ warns = tag_warnings(needs, structural_ids={"G-1"}, threshold=0.4)
240
+ msgs = {(t, m) for t, m in warns}
241
+ tags_warned = {t for t, _ in warns}
242
+ # G-1 is structural, searching is a singleton, search is used 4x (no singleton warning)
243
+ assert any(t == "G-1" and "declared goal/role/release/client" in m for t, m in msgs)
244
+ assert any(t == "searching" and "singleton" in m for t, m in msgs)
245
+ assert any("near-duplicate spelling" in m for _, m in msgs)
246
+ assert "search" not in {t for t, m in msgs if "singleton" in m} # 4 uses, not a singleton
247
+ assert tags_warned # sanity
@@ -262,6 +262,16 @@ 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_need_loads_tags(tmp_path: Path) -> None:
266
+ # #80: tags are a flat, many-to-many label list on a need.
267
+ cfg = _config(
268
+ tmp_path,
269
+ ("a.yaml", "needs:\n - id: N-1\n text: a need\n tags: [search, onboarding]\n"),
270
+ )
271
+ (need,) = load_needs(cfg)
272
+ assert need.tags == ("search", "onboarding")
273
+
274
+
265
275
  def test_goal_loads_description_and_target(tmp_path: Path) -> None:
266
276
  # #80: a goal is the home for the initiative — a description and an optional target window.
267
277
  from prodc.loader import load
@@ -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
 
@@ -302,6 +302,14 @@ def test_children_append_bare_id(tmp_path: Path) -> None:
302
302
  assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", None)]
303
303
 
304
304
 
305
+ def test_tags_append(tmp_path: Path) -> None:
306
+ # #80: tags are a plain string list; `tags+=X` appends like proofs.
307
+ cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: a need\n tags: [search]\n")
308
+ set_field(cfg, "N-1", "tags+=onboarding", expect_sha=None, dry_run=False)
309
+ need = next(n for n in load(cfg).needs if n.id == "N-1")
310
+ assert need.tags == ("search", "onboarding")
311
+
312
+
305
313
  def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
306
314
  cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
307
315
  with pytest.raises(WriteError, match="no need 'N-9'"):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes