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.
- {prodc-0.4.4 → prodc-0.4.5}/PKG-INFO +1 -1
- {prodc-0.4.4 → prodc-0.4.5}/pyproject.toml +1 -1
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/board.py +1 -1
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/app.py +16 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/commands.py +24 -5
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/render.py +23 -1
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/loader.py +15 -2
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/model.py +26 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/report.py +5 -5
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/status.py +47 -4
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/writer.py +6 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_principles.py +77 -2
- {prodc-0.4.4 → prodc-0.4.5}/.gitignore +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/CHANGELOG.md +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/LICENSE +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/README.md +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/__init__.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/adapters.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/agent.md +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/config.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/dedup.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/guide.md +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/py.typed +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/src/prodc/report_html.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_adapters.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_answer.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_callbacks.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_citation.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_cli.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_command_hook.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_contradicts.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_decisions.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_dedup.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_description.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_drafts.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_evalgate.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_importance.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_loader.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_models.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_needref.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_relink.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_report.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_report_html.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_review.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_roles.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_source_role.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_sources.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_staleness.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_status.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_suggest.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_variants.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_who_variants.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_withdrawn.py +0 -0
- {prodc-0.4.4 → prodc-0.4.5}/tests/test_writer.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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=
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
685
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|