prodc 0.3.66__tar.gz → 0.3.73__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.3.66 → prodc-0.3.73}/PKG-INFO +1 -1
- prodc-0.3.73/examples/quickstart/needs/jordan.yaml +40 -0
- {prodc-0.3.66 → prodc-0.3.73}/examples/quickstart/prodc.toml +5 -1
- {prodc-0.3.66 → prodc-0.3.73}/pyproject.toml +1 -1
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/adapters.py +111 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/agent.md +3 -2
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/board.py +2 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/app.py +13 -2
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/commands.py +34 -5
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/common.py +77 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/render.py +14 -2
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/config.py +9 -1
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/loader.py +26 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/model.py +23 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/status.py +101 -6
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/writer.py +19 -0
- prodc-0.3.73/tests/test_answer.py +154 -0
- prodc-0.3.73/tests/test_citation.py +39 -0
- prodc-0.3.73/tests/test_claim_gating.py +66 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_cli.py +47 -3
- prodc-0.3.73/tests/test_evalgate.py +100 -0
- prodc-0.3.73/tests/test_needref.py +175 -0
- prodc-0.3.66/examples/quickstart/needs/jordan.yaml +0 -24
- {prodc-0.3.66 → prodc-0.3.73}/.gitignore +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/CHANGELOG.md +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/LICENSE +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/README.md +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/__init__.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/dedup.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/py.typed +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/report.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/src/prodc/report_html.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_adapters.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_callbacks.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_command_hook.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_dedup.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_description.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_drafts.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_importance.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_loader.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_models.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_report.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_report_html.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_roles.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_sources.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_staleness.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_status.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_suggest.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_variants.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_who_variants.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_withdrawn.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_writer.py +0 -0
- {prodc-0.3.66 → prodc-0.3.73}/tests/test_writer_comments.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.73
|
|
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
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
who: jordan
|
|
2
|
+
label: "design partner"
|
|
3
|
+
needs:
|
|
4
|
+
- id: N-1
|
|
5
|
+
text: "see which orders are past due at a glance"
|
|
6
|
+
must: true
|
|
7
|
+
sources:
|
|
8
|
+
- kind: primary
|
|
9
|
+
who: Jordan
|
|
10
|
+
date: 2026-09-20
|
|
11
|
+
quote: "I lose the past-due ones in the list"
|
|
12
|
+
proofs:
|
|
13
|
+
- "web:orders.feature#Past due orders are highlighted"
|
|
14
|
+
|
|
15
|
+
- id: N-2
|
|
16
|
+
text: "export the day's delivery run sheet"
|
|
17
|
+
sources:
|
|
18
|
+
- kind: secondary
|
|
19
|
+
locator: issues.md#N-2
|
|
20
|
+
date: 2026-09-21
|
|
21
|
+
proofs: [] # sourced HOLE: the need is evidenced, but no test proves it yet
|
|
22
|
+
|
|
23
|
+
- id: N-3
|
|
24
|
+
text: "implement a settings page" # opinion (no source) + solution-in-disguise warning
|
|
25
|
+
|
|
26
|
+
# A product need is a hypothesis, same as a research question. Stating the `claim` (the warrant
|
|
27
|
+
# the bet would establish) silences the EARS/solution-verb lint — the honest act, not a mode.
|
|
28
|
+
# The experiment shipped and settled the question NO: DELIVERED + answer: no. It stays on the
|
|
29
|
+
# board and counts as a result — unlike `withdrawn`, which would hide that something was learned.
|
|
30
|
+
- id: N-4
|
|
31
|
+
text: "pin the daily summary to the top of the dashboard"
|
|
32
|
+
claim: "pinning the summary lifts next-day return visits"
|
|
33
|
+
sources:
|
|
34
|
+
- kind: primary
|
|
35
|
+
who: Jordan
|
|
36
|
+
date: 2026-09-22
|
|
37
|
+
quote: "I never scroll back up to the summary"
|
|
38
|
+
proofs:
|
|
39
|
+
- "ship:jordan@2026-10-05" # the A/B experiment ran and concluded
|
|
40
|
+
answer: no # return visits did not move — the bet is settled, and the answer is no
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# A minimal, runnable prodc project. From this directory:
|
|
2
2
|
# uv run prodc status
|
|
3
3
|
# N-1 shows DELIVERED (a tagged scenario proves it), N-2 is a sourced HOLE
|
|
4
|
-
# (evidence exists, no test yet), N-3 is an opinion with a solution-in-disguise warning
|
|
4
|
+
# (evidence exists, no test yet), N-3 is an opinion with a solution-in-disguise warning,
|
|
5
|
+
# N-4 is a shipped hypothesis that settled NO (DELIVERED + answer: no — a result, not a gap).
|
|
5
6
|
|
|
6
7
|
[prodc]
|
|
7
8
|
needs = ["needs/*.yaml"]
|
|
@@ -11,3 +12,6 @@ kind = "gherkin"
|
|
|
11
12
|
paths = ["features/*.feature"]
|
|
12
13
|
delivered = ["@implemented"]
|
|
13
14
|
pending = ["@pending"]
|
|
15
|
+
|
|
16
|
+
[proofs.ship]
|
|
17
|
+
kind = "accept" # a human records that an experiment ran and concluded (declared)
|
|
@@ -40,6 +40,23 @@ def _steps_passed(steps: list[object]) -> bool:
|
|
|
40
40
|
return True
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _as_score(val: object) -> float | None:
|
|
44
|
+
"""A numeric eval score from a results value: a bare number, or ``{"score": N}``.
|
|
45
|
+
|
|
46
|
+
``bool`` is a subclass of ``int`` but is a pass/fail, not a score, so it is rejected — a
|
|
47
|
+
boolean result belongs to :class:`OutcomeAdapter`, not the gate.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(val, bool):
|
|
50
|
+
return None
|
|
51
|
+
if isinstance(val, (int, float)):
|
|
52
|
+
return float(val)
|
|
53
|
+
if isinstance(val, dict):
|
|
54
|
+
inner = cast("dict[str, object]", val).get("score")
|
|
55
|
+
if isinstance(inner, (int, float)) and not isinstance(inner, bool):
|
|
56
|
+
return float(inner)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
def _git_head(repo: Path) -> str | None:
|
|
44
61
|
"""Short git rev of a checked-out repo, read from .git/HEAD (no git binary needed)."""
|
|
45
62
|
head = repo / ".git" / "HEAD"
|
|
@@ -486,6 +503,79 @@ class OutcomeAdapter:
|
|
|
486
503
|
return ProofResult(ProofState.PENDING, False, f"outcome '{payload}' recorded failing")
|
|
487
504
|
|
|
488
505
|
|
|
506
|
+
#: Comparators an evalgate locator may use, strongest-looking first so ``>=``/``<=`` match
|
|
507
|
+
#: before the bare ``>``/``<`` when scanning the locator.
|
|
508
|
+
_EVAL_OPS: dict[str, Callable[[float, float], bool]] = {
|
|
509
|
+
">=": lambda s, t: s >= t,
|
|
510
|
+
"<=": lambda s, t: s <= t,
|
|
511
|
+
">": lambda s, t: s > t,
|
|
512
|
+
"<": lambda s, t: s < t,
|
|
513
|
+
}
|
|
514
|
+
_EVAL_RE = re.compile(r"^(?P<key>.+?)(?P<op><=|>=|<|>)(?P<thr>[^<>=]+)$")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class EvalGateAdapter:
|
|
518
|
+
"""Resolves ``metric>=threshold`` against a recorded eval score (#75).
|
|
519
|
+
|
|
520
|
+
A scored sibling of :class:`OutcomeAdapter`: where that reads a pass/fail bool, this reads a
|
|
521
|
+
numeric score and gates it against a threshold carried in the locator (``accuracy>=0.9``), so
|
|
522
|
+
one adapter serves many metrics and cutoffs (``>=``, ``<=``, ``>``, ``<`` — ``<=`` for a loss
|
|
523
|
+
you want below a bound). The score is produced by an eval harness *outside* prodc and recorded
|
|
524
|
+
in a JSON file (``{"accuracy": {"score": 0.82}}`` or the bare ``{"accuracy": 0.82}``); prodc
|
|
525
|
+
only reads it — records-only, never runs the eval.
|
|
526
|
+
|
|
527
|
+
DELIVERED when the comparison holds; PENDING when the score is recorded but does not meet the
|
|
528
|
+
gate, or the metric is absent from a present file (recorded-below and not-yet-recorded are both
|
|
529
|
+
"not green", as in :class:`OutcomeAdapter`); BROKEN when the results file is absent or the
|
|
530
|
+
locator is malformed. The score, comparator and threshold go in the detail so the board shows
|
|
531
|
+
how far off it is.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
def __init__(self, root: Path, results: str) -> None:
|
|
535
|
+
self._path = root / results
|
|
536
|
+
self._data: dict[str, float] | None = None
|
|
537
|
+
self._loaded = False
|
|
538
|
+
|
|
539
|
+
def _load(self) -> dict[str, float] | None:
|
|
540
|
+
if not self._loaded:
|
|
541
|
+
self._loaded = True
|
|
542
|
+
if self._path.exists():
|
|
543
|
+
out: dict[str, float] = {}
|
|
544
|
+
raw: object = json.loads(self._path.read_text(encoding="utf-8"))
|
|
545
|
+
if isinstance(raw, dict):
|
|
546
|
+
for key, val in cast("dict[str, object]", raw).items():
|
|
547
|
+
score = _as_score(val)
|
|
548
|
+
if score is not None:
|
|
549
|
+
out[key] = score
|
|
550
|
+
self._data = out
|
|
551
|
+
return self._data
|
|
552
|
+
|
|
553
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
554
|
+
m = _EVAL_RE.match(payload.strip())
|
|
555
|
+
if not m:
|
|
556
|
+
return ProofResult(
|
|
557
|
+
ProofState.BROKEN, False, f"malformed evalgate '{payload}' (want metric>=threshold)"
|
|
558
|
+
)
|
|
559
|
+
key = m.group("key").strip()
|
|
560
|
+
op = m.group("op")
|
|
561
|
+
try:
|
|
562
|
+
threshold = float(m.group("thr").strip())
|
|
563
|
+
except ValueError:
|
|
564
|
+
return ProofResult(
|
|
565
|
+
ProofState.BROKEN, False, f"evalgate threshold not a number '{payload}'"
|
|
566
|
+
)
|
|
567
|
+
data = self._load()
|
|
568
|
+
if data is None:
|
|
569
|
+
return ProofResult(ProofState.BROKEN, False, f"no results file '{self._path.name}'")
|
|
570
|
+
if key not in data:
|
|
571
|
+
return ProofResult(ProofState.PENDING, False, f"metric '{key}' not in results")
|
|
572
|
+
score = data[key]
|
|
573
|
+
passed = _EVAL_OPS[op](score, threshold)
|
|
574
|
+
verdict = "pass" if passed else "below gate"
|
|
575
|
+
detail = f"{key} {score:g} {op} {threshold:g} ({verdict})"
|
|
576
|
+
return ProofResult(ProofState.DELIVERED if passed else ProofState.PENDING, False, detail)
|
|
577
|
+
|
|
578
|
+
|
|
489
579
|
class CommandProof:
|
|
490
580
|
"""Resolves a locator by running a user-supplied command — a proof hook (#70).
|
|
491
581
|
|
|
@@ -549,6 +639,27 @@ class PrototypeAdapter:
|
|
|
549
639
|
return ProofResult(ProofState.DELIVERED, True, detail)
|
|
550
640
|
|
|
551
641
|
|
|
642
|
+
class CitationAdapter:
|
|
643
|
+
"""Resolves ``ref@author@year`` to a cited published result — a declared DELIVERED (#75).
|
|
644
|
+
|
|
645
|
+
A research question can be settled by citing a result a human vouches for and prodc cannot
|
|
646
|
+
check (a published paper, a textbook theorem). Modelled on :class:`PrototypeAdapter`: a
|
|
647
|
+
*declared* DELIVERED (``*``), never BROKEN, because it is an assertion, like a tag. Kept
|
|
648
|
+
distinct from ``accept`` on purpose — ``accept`` records *who signed off*; a citation records
|
|
649
|
+
*what external result settles it*, and ``ref`` (the thing a reader follows to check the claim)
|
|
650
|
+
is the load-bearing payload ``accept`` has no slot for: ``accept:Smith2019@jorge@...`` would
|
|
651
|
+
read as "jorge accepted it", hiding that the truth is "Smith 2019 shows it".
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
655
|
+
ref, _, rest = payload.partition("@")
|
|
656
|
+
author, _, year = rest.partition("@")
|
|
657
|
+
detail = "cites " + (ref or "?")
|
|
658
|
+
detail += f" by {author}" if author else ""
|
|
659
|
+
detail += f" ({year})" if year else ""
|
|
660
|
+
return ProofResult(ProofState.DELIVERED, True, detail)
|
|
661
|
+
|
|
662
|
+
|
|
552
663
|
class External:
|
|
553
664
|
"""Wraps a proof adapter that resolves against a sibling repo checked out at ``repo``.
|
|
554
665
|
|
|
@@ -80,8 +80,9 @@ Read first, then act, then check.
|
|
|
80
80
|
| act on a need going green (notify a tracker, etc.) | `prodc sync` — runs the `[callbacks]` commands for needs whose state changed since the last sync (`--dry-run` to preview) |
|
|
81
81
|
| gate in CI | `prodc lint --strict` |
|
|
82
82
|
|
|
83
|
-
Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <
|
|
84
|
-
(roll up many
|
|
83
|
+
Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <path>…`
|
|
84
|
+
(roll up many boards into one scoreboard + totals; a path is a prodc.toml or a dir to discover
|
|
85
|
+
boards under), and `prodc diff <snapshot.json>` (what changed since a
|
|
85
86
|
saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
|
|
86
87
|
`--wide`, `--include-drafts`, and `--stale <days>`.
|
|
87
88
|
|
|
@@ -97,6 +97,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
97
97
|
"goal": need.goal,
|
|
98
98
|
"release": need.release,
|
|
99
99
|
"claim": need.claim,
|
|
100
|
+
"answer": need.answer.value if need.answer else None,
|
|
100
101
|
"withdrawn": (
|
|
101
102
|
{"reason": need.withdrawn.reason, "date": need.withdrawn.date}
|
|
102
103
|
if need.withdrawn
|
|
@@ -143,6 +144,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
143
144
|
"release": need.release,
|
|
144
145
|
"product": need.product,
|
|
145
146
|
"draft": need.draft,
|
|
147
|
+
"answer": need.answer.value if need.answer else None,
|
|
146
148
|
"withdrawn": (
|
|
147
149
|
{"reason": need.withdrawn.reason, "date": need.withdrawn.date}
|
|
148
150
|
if need.withdrawn
|
|
@@ -324,8 +324,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
324
324
|
)
|
|
325
325
|
ri.set_defaults(func=cmd_reinstate)
|
|
326
326
|
|
|
327
|
-
pf = sub.add_parser("portfolio", help="roll up many
|
|
328
|
-
pf.add_argument(
|
|
327
|
+
pf = sub.add_parser("portfolio", help="roll up many boards into one scoreboard")
|
|
328
|
+
pf.add_argument(
|
|
329
|
+
"configs",
|
|
330
|
+
nargs="+",
|
|
331
|
+
metavar="PATH",
|
|
332
|
+
help="prodc.toml paths and/or directories to discover boards under",
|
|
333
|
+
)
|
|
334
|
+
pf.add_argument(
|
|
335
|
+
"--max-depth",
|
|
336
|
+
type=int,
|
|
337
|
+
default=4,
|
|
338
|
+
help="how deep to descend when discovering boards under a directory (default 4)",
|
|
339
|
+
)
|
|
329
340
|
pf.add_argument("--json", action="store_true", help="machine-readable output")
|
|
330
341
|
pf.set_defaults(func=cmd_portfolio)
|
|
331
342
|
|
|
@@ -56,7 +56,15 @@ from ..writer import (
|
|
|
56
56
|
set_importance,
|
|
57
57
|
withdraw,
|
|
58
58
|
)
|
|
59
|
-
from .common import
|
|
59
|
+
from .common import (
|
|
60
|
+
RepoRow,
|
|
61
|
+
discover_configs,
|
|
62
|
+
emit_edit,
|
|
63
|
+
exit_code,
|
|
64
|
+
git_rev,
|
|
65
|
+
load_or_report,
|
|
66
|
+
portfolio_totals,
|
|
67
|
+
)
|
|
60
68
|
from .render import (
|
|
61
69
|
COLUMNS,
|
|
62
70
|
provenance_counts,
|
|
@@ -582,12 +590,33 @@ def _repo_row(config_path: str) -> RepoRow:
|
|
|
582
590
|
|
|
583
591
|
|
|
584
592
|
def cmd_portfolio(args: argparse.Namespace) -> int:
|
|
585
|
-
"""Roll up many
|
|
586
|
-
|
|
593
|
+
"""Roll up many boards into one scoreboard — coverage, must-HOLEs, provenance per board.
|
|
594
|
+
|
|
595
|
+
Each argument is a ``prodc.toml`` path or a directory to discover boards under (every
|
|
596
|
+
``prodc.toml`` beneath it, pruning VCS/vendor/build and hidden dirs, capped at ``--max-depth``).
|
|
597
|
+
Boards stay separate rows; a totals footer aggregates the clean ones. Never a merged board.
|
|
598
|
+
"""
|
|
599
|
+
paths: list[str] = []
|
|
600
|
+
for arg in args.configs:
|
|
601
|
+
p = Path(arg)
|
|
602
|
+
if p.is_dir():
|
|
603
|
+
paths.extend(str(c) for c in discover_configs(p, max_depth=args.max_depth))
|
|
604
|
+
else:
|
|
605
|
+
paths.append(arg)
|
|
606
|
+
seen: set[str] = set()
|
|
607
|
+
unique: list[str] = []
|
|
608
|
+
for path in paths:
|
|
609
|
+
key = str(Path(path).resolve())
|
|
610
|
+
if key not in seen:
|
|
611
|
+
seen.add(key)
|
|
612
|
+
unique.append(path)
|
|
613
|
+
rows = [_repo_row(path) for path in unique]
|
|
614
|
+
totals = portfolio_totals(rows)
|
|
587
615
|
if args.json:
|
|
588
|
-
|
|
616
|
+
payload = {"boards": [asdict(r) for r in rows], "totals": asdict(totals)}
|
|
617
|
+
print(json.dumps(payload, indent=2))
|
|
589
618
|
else:
|
|
590
|
-
print(render_portfolio(rows))
|
|
619
|
+
print(render_portfolio(rows, totals))
|
|
591
620
|
return 2 if any(r.error is not None for r in rows) else 0
|
|
592
621
|
|
|
593
622
|
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
12
|
import json
|
|
13
|
+
import os
|
|
13
14
|
import sys
|
|
14
15
|
from dataclasses import dataclass
|
|
15
16
|
from pathlib import Path
|
|
@@ -100,3 +101,79 @@ class RepoRow:
|
|
|
100
101
|
coverage: float
|
|
101
102
|
provenance: dict[str, int]
|
|
102
103
|
error: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class PortfolioTotals:
|
|
108
|
+
"""The aggregate across every board in a portfolio — a footer, not a merged board.
|
|
109
|
+
|
|
110
|
+
Error rows are excluded (a board that would not load contributes no counts). ``coverage`` is
|
|
111
|
+
the pooled ``delivered / total``, which is not the mean of the per-board percentages.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
boards: int # how many boards loaded cleanly and are summed here
|
|
115
|
+
delivered: int
|
|
116
|
+
total: int
|
|
117
|
+
must_hole: int
|
|
118
|
+
broken: int
|
|
119
|
+
coverage: float
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def portfolio_totals(rows: list[RepoRow]) -> PortfolioTotals:
|
|
123
|
+
"""Sum the clean rows into a :class:`PortfolioTotals` footer."""
|
|
124
|
+
ok = [r for r in rows if r.error is None]
|
|
125
|
+
delivered = sum(r.delivered for r in ok)
|
|
126
|
+
total = sum(r.total for r in ok)
|
|
127
|
+
return PortfolioTotals(
|
|
128
|
+
boards=len(ok),
|
|
129
|
+
delivered=delivered,
|
|
130
|
+
total=total,
|
|
131
|
+
must_hole=sum(r.must_hole for r in ok),
|
|
132
|
+
broken=sum(r.broken for r in ok),
|
|
133
|
+
coverage=round(delivered / total, 4) if total else 0.0,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Directory names never descended into during board discovery: VCS, virtualenvs, dependency and
|
|
138
|
+
# build trees. Any name starting with "." is pruned too (handled in the walk), so a vendored or
|
|
139
|
+
# example prodc.toml buried in one of these is never mistaken for a board.
|
|
140
|
+
_PRUNE_DIRS = frozenset(
|
|
141
|
+
{
|
|
142
|
+
".git",
|
|
143
|
+
"node_modules",
|
|
144
|
+
".venv",
|
|
145
|
+
"venv",
|
|
146
|
+
"__pycache__",
|
|
147
|
+
"dist",
|
|
148
|
+
"build",
|
|
149
|
+
"target",
|
|
150
|
+
".tox",
|
|
151
|
+
".mypy_cache",
|
|
152
|
+
".ruff_cache",
|
|
153
|
+
"site-packages",
|
|
154
|
+
"vendor",
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def discover_configs(root: Path, *, max_depth: int = 4) -> list[Path]:
|
|
160
|
+
"""Every ``prodc.toml`` under ``root``, for the portfolio roll-up — bounded so it can't run away.
|
|
161
|
+
|
|
162
|
+
A plain recursive scan is a footgun: it descends into ``.git``/``node_modules``/build trees and
|
|
163
|
+
picks up vendored or example configs. So this prunes those (and every ``.``-prefixed dir) and
|
|
164
|
+
caps the descent at ``max_depth`` directories below ``root``. Returns sorted, de-duplicated
|
|
165
|
+
paths; a board is the directory that holds the ``prodc.toml``.
|
|
166
|
+
"""
|
|
167
|
+
root = root.resolve()
|
|
168
|
+
found: set[Path] = set()
|
|
169
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
170
|
+
if "prodc.toml" in filenames:
|
|
171
|
+
found.add(Path(dirpath) / "prodc.toml")
|
|
172
|
+
depth = len(Path(dirpath).relative_to(root).parts)
|
|
173
|
+
# Prune in place so os.walk never descends into the skipped dirs, then stop at the depth cap.
|
|
174
|
+
dirnames[:] = (
|
|
175
|
+
[]
|
|
176
|
+
if depth >= max_depth
|
|
177
|
+
else [d for d in dirnames if d not in _PRUNE_DIRS and not d.startswith(".")]
|
|
178
|
+
)
|
|
179
|
+
return sorted(found)
|
|
@@ -22,7 +22,7 @@ from ..model import (
|
|
|
22
22
|
ears_shape,
|
|
23
23
|
)
|
|
24
24
|
from ..status import Handoff, Recurrence, Summary, rank, state_priority
|
|
25
|
-
from .common import RepoRow, brief_open
|
|
25
|
+
from .common import PortfolioTotals, RepoRow, brief_open
|
|
26
26
|
|
|
27
27
|
_LABEL = {
|
|
28
28
|
NeedState.DELIVERED: "DELIVERED",
|
|
@@ -273,6 +273,8 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
273
273
|
f" grounds: {_LABEL[r.state]}{nm} warrant: {_warrant(need)}",
|
|
274
274
|
f" shape: {ears_shape(need.text).value}",
|
|
275
275
|
]
|
|
276
|
+
if need.answer:
|
|
277
|
+
lines.append(f" answer: {need.answer.value}")
|
|
276
278
|
if need.withdrawn:
|
|
277
279
|
lines.append(f" withdrawn: {need.withdrawn.reason} ({need.withdrawn.date})")
|
|
278
280
|
if need.description:
|
|
@@ -378,7 +380,9 @@ def render_argument(model: Model, defects: list[str]) -> str:
|
|
|
378
380
|
return "\n".join(out)
|
|
379
381
|
|
|
380
382
|
|
|
381
|
-
def render_portfolio(rows: list[RepoRow]) -> str:
|
|
383
|
+
def render_portfolio(rows: list[RepoRow], totals: PortfolioTotals | None = None) -> str:
|
|
384
|
+
if not rows:
|
|
385
|
+
return " no boards found"
|
|
382
386
|
out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov prim/sec/asm/op"]
|
|
383
387
|
for row in rows:
|
|
384
388
|
if row.error is not None:
|
|
@@ -388,4 +392,12 @@ def render_portfolio(rows: list[RepoRow]) -> str:
|
|
|
388
392
|
prov = f"{p['primary']}/{p['secondary']}/{p['assumption']}/{p['opinion']}"
|
|
389
393
|
cov = f"{round(row.coverage * 100)}%"
|
|
390
394
|
out.append(f" {row.repo:<18} {cov:>4} {row.must_hole:>9} {row.broken:>6} {prov}")
|
|
395
|
+
if totals is not None:
|
|
396
|
+
cov = f"{round(totals.coverage * 100)}%"
|
|
397
|
+
label = f"TOTAL ({totals.boards} boards)"
|
|
398
|
+
out.append(f" {'─' * 48}")
|
|
399
|
+
out.append(
|
|
400
|
+
f" {label:<18} {cov:>4} {totals.must_hole:>9} {totals.broken:>6} "
|
|
401
|
+
f"{totals.delivered}/{totals.total} delivered"
|
|
402
|
+
)
|
|
391
403
|
return "\n".join(out)
|
|
@@ -10,9 +10,11 @@ from typing import cast
|
|
|
10
10
|
from .adapters import (
|
|
11
11
|
AcceptAdapter,
|
|
12
12
|
Adapter,
|
|
13
|
+
CitationAdapter,
|
|
13
14
|
CommandProof,
|
|
14
15
|
CommandSource,
|
|
15
16
|
CoverageAdapter,
|
|
17
|
+
EvalGateAdapter,
|
|
16
18
|
External,
|
|
17
19
|
FieldAdapter,
|
|
18
20
|
FilesAdapter,
|
|
@@ -190,6 +192,8 @@ class Config:
|
|
|
190
192
|
inner = AcceptAdapter()
|
|
191
193
|
elif kind == "prototype":
|
|
192
194
|
inner = PrototypeAdapter()
|
|
195
|
+
elif kind == "citation":
|
|
196
|
+
inner = CitationAdapter()
|
|
193
197
|
elif kind == "coverage":
|
|
194
198
|
cat = tbl.get("catalog", "")
|
|
195
199
|
inner = CoverageAdapter(
|
|
@@ -202,13 +206,17 @@ class Config:
|
|
|
202
206
|
if not results_s:
|
|
203
207
|
raise ConfigError(f"[proofs.{name}]: external needs results = <json file>")
|
|
204
208
|
inner = OutcomeAdapter(root=proof_root, results=results_s)
|
|
209
|
+
elif kind == "evalgate":
|
|
210
|
+
if not results_s:
|
|
211
|
+
raise ConfigError(f"[proofs.{name}]: evalgate needs results = <json file>")
|
|
212
|
+
inner = EvalGateAdapter(root=proof_root, results=results_s)
|
|
205
213
|
elif kind == "command":
|
|
206
214
|
command, timeout = _hook_command(tbl, f"[proofs.{name}]")
|
|
207
215
|
inner = CommandProof(root=proof_root, command=command, timeout=timeout)
|
|
208
216
|
else:
|
|
209
217
|
raise ConfigError(
|
|
210
218
|
f"[proofs.{name}]: unknown kind {kind!r} "
|
|
211
|
-
"(gherkin|pytest|files|accept|prototype|coverage|field|external|command)"
|
|
219
|
+
"(gherkin|pytest|files|accept|prototype|citation|coverage|field|external|evalgate|command)"
|
|
212
220
|
)
|
|
213
221
|
adapters[name] = (
|
|
214
222
|
External(inner, root / repo_field) if isinstance(repo_field, str) else inner
|
|
@@ -17,6 +17,7 @@ import yaml
|
|
|
17
17
|
|
|
18
18
|
from .config import Config
|
|
19
19
|
from .model import (
|
|
20
|
+
Answer,
|
|
20
21
|
ArgKind,
|
|
21
22
|
ArgNode,
|
|
22
23
|
Attestation,
|
|
@@ -105,6 +106,29 @@ def _attestation(raw: object, where: str) -> Attestation | None:
|
|
|
105
106
|
)
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
def _answer(raw: object, where: str) -> Answer | None:
|
|
110
|
+
"""Parse ``answer: yes|no|inconclusive`` into an :class:`Answer`, validated like other enums.
|
|
111
|
+
|
|
112
|
+
The direction a settled research question resolved; absent on a product need. Same
|
|
113
|
+
``Enum(name) except ValueError`` pattern as ``SourceKind``/``ArgKind`` so an unknown value is
|
|
114
|
+
a :class:`LoaderError` the author owns, not silent junk.
|
|
115
|
+
|
|
116
|
+
YAML 1.1 reads a bare ``yes``/``no`` as a boolean, so ``answer: no`` arrives as ``False`` —
|
|
117
|
+
coerced back to the string here, the same way ``_opt_str`` accepts a bare ``date:`` that
|
|
118
|
+
parsed as a date. ``inconclusive`` is a plain string and needs no coercion.
|
|
119
|
+
"""
|
|
120
|
+
if raw is None:
|
|
121
|
+
return None
|
|
122
|
+
if isinstance(raw, bool):
|
|
123
|
+
raw = "yes" if raw else "no"
|
|
124
|
+
name = _as_str(raw, where)
|
|
125
|
+
try:
|
|
126
|
+
return Answer(name)
|
|
127
|
+
except ValueError:
|
|
128
|
+
answers = "|".join(a.value for a in Answer)
|
|
129
|
+
raise LoaderError(f"{where}: unknown answer {name!r} ({answers})") from None
|
|
130
|
+
|
|
131
|
+
|
|
108
132
|
def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
109
133
|
"""Parse ``withdrawn: {reason, date}`` into a :class:`Withdrawal` (#74).
|
|
110
134
|
|
|
@@ -178,6 +202,7 @@ def _need(
|
|
|
178
202
|
product = _as_bool(tbl["product"], f"{where}.product") if "product" in tbl else True
|
|
179
203
|
draft = _as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False
|
|
180
204
|
withdrawn = _withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn")
|
|
205
|
+
answer = _answer(tbl.get("answer"), f"{where}.answer")
|
|
181
206
|
|
|
182
207
|
sources = tuple(
|
|
183
208
|
_source(s, f"{where}.sources[{i}]")
|
|
@@ -236,6 +261,7 @@ def _need(
|
|
|
236
261
|
importance_set_at=importance_set_at,
|
|
237
262
|
draft=draft,
|
|
238
263
|
withdrawn=withdrawn,
|
|
264
|
+
answer=answer,
|
|
239
265
|
)
|
|
240
266
|
|
|
241
267
|
|
|
@@ -48,6 +48,26 @@ 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 Answer(StrEnum):
|
|
52
|
+
"""Which way a settled question resolved — the research board's direction axis.
|
|
53
|
+
|
|
54
|
+
A product need has one axis: is the outcome achieved? A research question has two: is it
|
|
55
|
+
*settled* (DELIVERED, the proof resolved green) and, if so, *which way*. DELIVERED means
|
|
56
|
+
settled; ``answer`` records the direction. A disproven hypothesis is therefore a settled
|
|
57
|
+
result that still counts toward coverage — DELIVERED with ``answer: no`` — not a gap.
|
|
58
|
+
|
|
59
|
+
Direction is always authored, never computed: an adapter can only map a proof to
|
|
60
|
+
delivered/pending/broken/unresolved, so no adapter can emit a settled-NO. The act of
|
|
61
|
+
concluding "this is the answer, and it is no" is the author's decision, so ``answer`` lives
|
|
62
|
+
on the :class:`Need` (authored lifecycle), not on a proof or need result (computed). A product
|
|
63
|
+
board never sets it and behaves exactly as before.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
YES = "yes" # the question settled affirmatively
|
|
67
|
+
NO = "no" # the question settled negatively — a result, not a gap (e.g. a disproven hypothesis)
|
|
68
|
+
INCONCLUSIVE = "inconclusive" # settled that the evidence cannot decide it either way
|
|
69
|
+
|
|
70
|
+
|
|
51
71
|
@dataclass(frozen=True)
|
|
52
72
|
class Source:
|
|
53
73
|
"""Where a need came from. Absence of any Source makes the need an opinion."""
|
|
@@ -179,6 +199,9 @@ class Need:
|
|
|
179
199
|
product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
|
|
180
200
|
draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
|
|
181
201
|
withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
|
|
202
|
+
answer: Answer | None = (
|
|
203
|
+
None # which way a settled need resolved (yes/no/inconclusive); None=open-ended
|
|
204
|
+
)
|
|
182
205
|
not_duplicate_of: tuple[str, ...] = () # ids this need is confirmed NOT a duplicate of (#55)
|
|
183
206
|
not_variant_of: tuple[str, ...] = () # ids this need is confirmed NOT a shared variant of (#56)
|
|
184
207
|
|