prodc 0.3.73__tar.gz → 0.3.75__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.73 → prodc-0.3.75}/PKG-INFO +1 -1
- {prodc-0.3.73 → prodc-0.3.75}/pyproject.toml +1 -1
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/agent.md +6 -3
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/board.py +9 -5
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/cli/app.py +36 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/cli/commands.py +174 -1
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/cli/render.py +6 -1
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/cli/serialize.py +1 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/config.py +7 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/loader.py +19 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/model.py +2 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/report_html.py +1 -1
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/writer.py +46 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_cli.py +12 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_drafts.py +44 -0
- prodc-0.3.75/tests/test_review.py +121 -0
- prodc-0.3.75/tests/test_source_role.py +126 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_writer.py +66 -1
- {prodc-0.3.73 → prodc-0.3.75}/.gitignore +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/CHANGELOG.md +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/LICENSE +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/README.md +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/__init__.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/adapters.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/dedup.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/py.typed +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/report.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/src/prodc/status.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_adapters.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_answer.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_callbacks.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_citation.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_command_hook.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_dedup.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_description.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_evalgate.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_importance.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_loader.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_models.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_needref.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_report.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_report_html.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_roles.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_sources.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_staleness.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_status.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_suggest.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_variants.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_who_variants.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/tests/test_withdrawn.py +0 -0
- {prodc-0.3.73 → prodc-0.3.75}/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.75
|
|
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
|
|
@@ -49,14 +49,16 @@ Read first, then act, then check.
|
|
|
49
49
|
3. **When someone describes a want**, decide: is there real evidence? If a user said it, capture
|
|
50
50
|
it as `primary`. If it is your own inference, record it as an `assumption` and let it show as the
|
|
51
51
|
weaker signal it is. Stage anything still half-formed with `add … --draft` — it loads but stays off the
|
|
52
|
-
counted board until you promote it
|
|
52
|
+
counted board until you promote it. `prodc review` is the triage surface: it lists the staged
|
|
53
|
+
drafts with a duplicate score, and `--promote <id>…` / `--promote-all` move them onto the board
|
|
54
|
+
while `--drop <id>…` deletes the ones that were noise.
|
|
53
55
|
4. **Find the proof.** For a HOLE need, `prodc suggest <id>` ranks candidate proofs from the test
|
|
54
56
|
suite; `prodc suggest <id> --apply` writes the best one (preview with `--dry-run`).
|
|
55
57
|
5. **Check for duplication and recurrence.** `prodc dupes` flags near-duplicate needs;
|
|
56
58
|
`prodc recurrence --suggest` finds the same want across clients that could share one baseline.
|
|
57
59
|
6. **Gate before you hand off.** `prodc lint` is the red gate: it exits non-zero on a BROKEN proof
|
|
58
60
|
or a must-have opinion. `prodc lint --strict` *also* fails on any hygiene warning (shared proofs,
|
|
59
|
-
unlinked recurrence, stale evidence). When you want a coverage threshold in CI, `prodc status`
|
|
61
|
+
unlinked recurrence, stale evidence, drafts left staged too long via `[lint].draft_max_age_days`). When you want a coverage threshold in CI, `prodc status`
|
|
60
62
|
carries its own gate flags (`--min-coverage`, `--max-dangling`, `--max-orphans`, `--strict`).
|
|
61
63
|
|
|
62
64
|
## The commands you will use most
|
|
@@ -68,8 +70,9 @@ Read first, then act, then check.
|
|
|
68
70
|
| rank the backlog | `prodc plan` |
|
|
69
71
|
| prep a client meeting | `prodc brief <client>` |
|
|
70
72
|
| record a new need (with honest provenance) | `prodc add need <id> --text "…" --who <you> --kind assumption --file needs/<f>.yaml` |
|
|
73
|
+
| triage staged drafts | `prodc review` — list them; `--promote <id>…`/`--promote-all` onto the board, `--drop <id>…` to delete |
|
|
71
74
|
| declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
|
|
72
|
-
| attach evidence after a user said something | `prodc capture <id> --kind primary --who <person> --date <ISO> --quote "…"` |
|
|
75
|
+
| attach evidence after a user said something | `prodc capture <id> --kind primary --who <person> [--role <id>] --date <ISO> --quote "…"` (`--role` records which declared role vouched for it) |
|
|
73
76
|
| set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
|
|
74
77
|
| propose / attach a proof for a HOLE | `prodc suggest <id> [--apply] [--dry-run]` |
|
|
75
78
|
| link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
|
|
@@ -48,16 +48,19 @@ from .status import (
|
|
|
48
48
|
def warrant(need: Need) -> str:
|
|
49
49
|
"""The warrant column: the strongest source kind, or 'opinion' when there is no source at all.
|
|
50
50
|
|
|
51
|
-
A `primary` source names its person (`primary:who`)
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
A `primary` source names its person (`primary:who`, and `primary:who (role)` when that person's
|
|
52
|
+
role is recorded, #52); `secondary`/`assumption` show the kind. A need with any source is shown
|
|
53
|
+
by that kind even if it is an opinion (only an assumption) — the opinion roll-up lives in the
|
|
54
|
+
gap counts, this column shows the literal evidence tier.
|
|
54
55
|
"""
|
|
55
56
|
strongest = need.strongest_kind # the one source-strength ranking lives on the model
|
|
56
57
|
if strongest is None:
|
|
57
58
|
return "opinion"
|
|
58
59
|
if strongest is SourceKind.PRIMARY:
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
src = next((s for s in need.sources if s.kind is SourceKind.PRIMARY and s.who), None)
|
|
61
|
+
if src is None:
|
|
62
|
+
return "primary"
|
|
63
|
+
return f"primary:{src.who}" + (f" ({src.role})" if src.role else "")
|
|
61
64
|
return strongest.value
|
|
62
65
|
|
|
63
66
|
|
|
@@ -120,6 +123,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
120
123
|
{
|
|
121
124
|
"kind": s.kind.value,
|
|
122
125
|
"who": s.who,
|
|
126
|
+
"role": s.role,
|
|
123
127
|
"date": s.date,
|
|
124
128
|
"quote": s.quote,
|
|
125
129
|
"locator": s.locator,
|
|
@@ -28,6 +28,7 @@ from .commands import (
|
|
|
28
28
|
cmd_recurrence,
|
|
29
29
|
cmd_reinstate,
|
|
30
30
|
cmd_report,
|
|
31
|
+
cmd_review,
|
|
31
32
|
cmd_set,
|
|
32
33
|
cmd_show,
|
|
33
34
|
cmd_status,
|
|
@@ -224,6 +225,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
224
225
|
"(interview/artifact/proxy/internal accepted as aliases)",
|
|
225
226
|
)
|
|
226
227
|
cp.add_argument("--who", default=None, help="the person the evidence came from")
|
|
228
|
+
cp.add_argument(
|
|
229
|
+
"--role",
|
|
230
|
+
default=None,
|
|
231
|
+
metavar="ID",
|
|
232
|
+
help="the declared role the person spoke as (validated against roles:) — whose signal it is (#52)",
|
|
233
|
+
)
|
|
227
234
|
cp.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-05)")
|
|
228
235
|
cp.add_argument("--quote", default=None, help="a verbatim quote (for a primary source)")
|
|
229
236
|
cp.add_argument("--locator", default=None, help="a source locator (for a secondary source)")
|
|
@@ -324,6 +331,35 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
324
331
|
)
|
|
325
332
|
ri.set_defaults(func=cmd_reinstate)
|
|
326
333
|
|
|
334
|
+
rv = sub.add_parser(
|
|
335
|
+
"review", help="triage staged drafts: list them, or --promote / --drop a batch (#60)"
|
|
336
|
+
)
|
|
337
|
+
rv.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
338
|
+
rv.add_argument("--json", action="store_true", help="machine-readable output")
|
|
339
|
+
rv.add_argument(
|
|
340
|
+
"--promote",
|
|
341
|
+
nargs="+",
|
|
342
|
+
default=None,
|
|
343
|
+
metavar="ID",
|
|
344
|
+
help="promote these drafts onto the board (sets draft=false)",
|
|
345
|
+
)
|
|
346
|
+
rv.add_argument(
|
|
347
|
+
"--promote-all",
|
|
348
|
+
action="store_true",
|
|
349
|
+
help="promote every staged draft (except any named in --drop)",
|
|
350
|
+
)
|
|
351
|
+
rv.add_argument(
|
|
352
|
+
"--drop",
|
|
353
|
+
nargs="+",
|
|
354
|
+
default=None,
|
|
355
|
+
metavar="ID",
|
|
356
|
+
help="delete these drafts outright (a draft, never a real need — withdraw that)",
|
|
357
|
+
)
|
|
358
|
+
rv.add_argument(
|
|
359
|
+
"--dry-run", action="store_true", help="validate and show the diffs, don't write"
|
|
360
|
+
)
|
|
361
|
+
rv.set_defaults(func=cmd_review)
|
|
362
|
+
|
|
327
363
|
pf = sub.add_parser("portfolio", help="roll up many boards into one scoreboard")
|
|
328
364
|
pf.add_argument(
|
|
329
365
|
"configs",
|
|
@@ -50,6 +50,7 @@ from ..writer import (
|
|
|
50
50
|
add_role,
|
|
51
51
|
attest,
|
|
52
52
|
capture_source,
|
|
53
|
+
delete_need,
|
|
53
54
|
link_variant_group,
|
|
54
55
|
reinstate,
|
|
55
56
|
set_field,
|
|
@@ -136,6 +137,47 @@ def _annotate_importance_warnings(results: list[NeedResult], cfg: Config) -> Non
|
|
|
136
137
|
)
|
|
137
138
|
|
|
138
139
|
|
|
140
|
+
def _annotate_draft_age_warnings(results: list[NeedResult], cfg: Config) -> None:
|
|
141
|
+
"""Flag staged drafts older than `[lint].draft_max_age_days`, so a batch can't be forgotten (#60).
|
|
142
|
+
|
|
143
|
+
Mirrors the importance-revisit check: a draft with no `draft_added` stamp is flagged undated
|
|
144
|
+
(it cannot be aged, so it is not silently exempt); a dated draft past the limit is flagged with
|
|
145
|
+
its age. Only runs when the knob is set.
|
|
146
|
+
"""
|
|
147
|
+
if cfg.draft_max_age_days is None:
|
|
148
|
+
return
|
|
149
|
+
today = date.today()
|
|
150
|
+
for r in results:
|
|
151
|
+
if not r.need.draft or r.need.is_withdrawn:
|
|
152
|
+
continue
|
|
153
|
+
when = r.need.draft_added
|
|
154
|
+
if when is None:
|
|
155
|
+
r.warnings.append("draft with no staged date (`add --draft` stamps one) — #60")
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
days = (today - date.fromisoformat(when)).days
|
|
159
|
+
except ValueError:
|
|
160
|
+
continue
|
|
161
|
+
if days > cfg.draft_max_age_days:
|
|
162
|
+
r.warnings.append(
|
|
163
|
+
f"draft staged {days}d ago (> {cfg.draft_max_age_days}d) — promote or drop (#60)"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _annotate_label_deprecation(results: list[NeedResult]) -> None:
|
|
168
|
+
"""Nudge needs still carrying the free-text `label` toward role + client (#52, deprecation).
|
|
169
|
+
|
|
170
|
+
Since Role (#52) carries the archetype and `client` the org, the per-need `label` is redundant —
|
|
171
|
+
"design partner, Sincerely" is `role` + `client`. Soft only: `label` is still parsed and still
|
|
172
|
+
renders; this flags it so a board can migrate off it before it is removed.
|
|
173
|
+
"""
|
|
174
|
+
for r in results:
|
|
175
|
+
if r.need.label:
|
|
176
|
+
r.warnings.append(
|
|
177
|
+
f"'label' ({r.need.label!r}) is deprecated — express it with role + client (#52)"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
139
181
|
def _lint_errors(results: list[NeedResult]) -> list[str]:
|
|
140
182
|
"""Hard problems a lint gate fails on: a dangling proof, or a must-have with no evidence."""
|
|
141
183
|
errors: list[str] = []
|
|
@@ -184,6 +226,8 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
184
226
|
f"who may name the same person: {whos} (needs {', '.join(v.need_ids)}) — #61"
|
|
185
227
|
)
|
|
186
228
|
_annotate_importance_warnings(results, cfg)
|
|
229
|
+
_annotate_draft_age_warnings(results, cfg)
|
|
230
|
+
_annotate_label_deprecation(results)
|
|
187
231
|
errors = _lint_errors(results)
|
|
188
232
|
warnings = [
|
|
189
233
|
(r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
|
|
@@ -531,6 +575,132 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
|
|
|
531
575
|
return emit_edit(edit, args.dry_run, args.json)
|
|
532
576
|
|
|
533
577
|
|
|
578
|
+
def cmd_review(args: argparse.Namespace) -> int:
|
|
579
|
+
"""Triage draft needs: list them with a duplicate score, or --promote / --drop a batch (#60).
|
|
580
|
+
|
|
581
|
+
The companion to `add --draft`: drafts pile up staged off the board, and this is the surface
|
|
582
|
+
that clears them. With no action flag it lists each draft with its provenance and the nearest
|
|
583
|
+
existing need (a dedup score, reusing #55), so a reviewer can see what is a duplicate before
|
|
584
|
+
promoting. `--promote` flips `draft=false` (sugar over `set`); `--drop` deletes the draft
|
|
585
|
+
outright (the #60 delete-need path). Both act only on drafts — a real need is withdrawn, not
|
|
586
|
+
dropped, so its record survives.
|
|
587
|
+
"""
|
|
588
|
+
loaded = load_or_report(args)
|
|
589
|
+
if loaded is None:
|
|
590
|
+
return 2
|
|
591
|
+
cfg, model = loaded
|
|
592
|
+
needs = list(model.needs)
|
|
593
|
+
drafts = [n for n in needs if n.draft and not n.is_withdrawn]
|
|
594
|
+
draft_ids = {n.id for n in drafts}
|
|
595
|
+
|
|
596
|
+
drop = list(args.drop or [])
|
|
597
|
+
if args.promote_all:
|
|
598
|
+
promote = [n.id for n in drafts if n.id not in drop]
|
|
599
|
+
else:
|
|
600
|
+
promote = list(args.promote or [])
|
|
601
|
+
|
|
602
|
+
unknown = [i for i in (*promote, *drop) if i not in draft_ids]
|
|
603
|
+
if unknown:
|
|
604
|
+
print(
|
|
605
|
+
f"prodc: not a staged draft: {', '.join(unknown)} "
|
|
606
|
+
"(review acts only on drafts — withdraw a real need instead)",
|
|
607
|
+
file=sys.stderr,
|
|
608
|
+
)
|
|
609
|
+
return 2
|
|
610
|
+
both = sorted(set(promote) & set(drop))
|
|
611
|
+
if both:
|
|
612
|
+
print(f"prodc: {', '.join(both)} named in both --promote and --drop", file=sys.stderr)
|
|
613
|
+
return 2
|
|
614
|
+
|
|
615
|
+
if not promote and not drop:
|
|
616
|
+
return _review_list(cfg, drafts, needs, args.json)
|
|
617
|
+
return _review_apply(cfg, promote, drop, args)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _review_list(cfg: Config, drafts: list[Need], needs: list[Need], as_json: bool) -> int:
|
|
621
|
+
"""List the staged drafts with provenance and the nearest existing need (read-only)."""
|
|
622
|
+
rows: list[dict[str, object]] = []
|
|
623
|
+
for d in sorted(drafts, key=lambda n: n.id):
|
|
624
|
+
others = [n for n in needs if n.id != d.id]
|
|
625
|
+
hits = matches_for(d.text, others, cfg.duplicate_threshold, exclude_id=d.id)
|
|
626
|
+
dup = {"id": hits[0][0].id, "score": round(hits[0][1], 4)} if hits else None
|
|
627
|
+
rows.append(
|
|
628
|
+
{
|
|
629
|
+
"id": d.id,
|
|
630
|
+
"text": d.text,
|
|
631
|
+
"who": d.who,
|
|
632
|
+
"client": d.client,
|
|
633
|
+
"sources": [s.kind.value for s in d.sources],
|
|
634
|
+
"staged": d.draft_added,
|
|
635
|
+
"duplicate": dup,
|
|
636
|
+
}
|
|
637
|
+
)
|
|
638
|
+
if as_json:
|
|
639
|
+
print(json.dumps({"drafts": rows}, indent=2))
|
|
640
|
+
return 0
|
|
641
|
+
for r in rows:
|
|
642
|
+
who = f" — {r['who']}" if r["who"] else ""
|
|
643
|
+
client = f" [{r['client']}]" if r["client"] else ""
|
|
644
|
+
staged = f" (staged {r['staged']})" if r["staged"] else ""
|
|
645
|
+
print(f"{r['id']}{who}{client}{staged}: {r['text']}")
|
|
646
|
+
kinds = cast("list[str]", r["sources"])
|
|
647
|
+
if kinds:
|
|
648
|
+
print(f" sources: {', '.join(kinds)}")
|
|
649
|
+
dup = cast("dict[str, object] | None", r["duplicate"])
|
|
650
|
+
if dup is not None:
|
|
651
|
+
print(f" possible duplicate of {dup['id']} ({dup['score']:.2f}) — #55")
|
|
652
|
+
print(f"review: {len(rows)} draft(s) staged")
|
|
653
|
+
return 0
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _review_apply(
|
|
657
|
+
cfg: Config, promote: list[str], drop: list[str], args: argparse.Namespace
|
|
658
|
+
) -> int:
|
|
659
|
+
"""Promote (`draft=false`) and/or drop (delete) the named drafts, each a validated edit (#60).
|
|
660
|
+
|
|
661
|
+
Sequential and independent, like `suggest --apply`: each need is one reload-gated, single-file
|
|
662
|
+
write applied in id order, and a rejection aborts with a non-zero exit (earlier writes stand).
|
|
663
|
+
"""
|
|
664
|
+
applied: list[tuple[str, str, Edit]] = [] # (action, need_id, edit)
|
|
665
|
+
try:
|
|
666
|
+
for nid in sorted(promote):
|
|
667
|
+
edit = set_field(cfg, nid, "draft=false", expect_sha=None, dry_run=args.dry_run)
|
|
668
|
+
applied.append(("promote", nid, edit))
|
|
669
|
+
for nid in sorted(drop):
|
|
670
|
+
edit = delete_need(cfg, nid, dry_run=args.dry_run)
|
|
671
|
+
applied.append(("drop", nid, edit))
|
|
672
|
+
except WriteError as exc:
|
|
673
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
674
|
+
return 2
|
|
675
|
+
if args.json:
|
|
676
|
+
print(
|
|
677
|
+
json.dumps(
|
|
678
|
+
[
|
|
679
|
+
{
|
|
680
|
+
"action": action,
|
|
681
|
+
"need": nid,
|
|
682
|
+
"file": edit.path.name,
|
|
683
|
+
"dry_run": args.dry_run,
|
|
684
|
+
"diff": edit.diff,
|
|
685
|
+
}
|
|
686
|
+
for action, nid, edit in applied
|
|
687
|
+
],
|
|
688
|
+
indent=2,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
return 0
|
|
692
|
+
for action, nid, edit in applied:
|
|
693
|
+
verb = "would promote" if action == "promote" else "would drop"
|
|
694
|
+
if not args.dry_run:
|
|
695
|
+
verb = "promoted" if action == "promote" else "dropped"
|
|
696
|
+
print(f"{verb} {nid} in {edit.path.name}")
|
|
697
|
+
print(edit.diff, end="")
|
|
698
|
+
n_prom = sum(1 for row in applied if row[0] == "promote")
|
|
699
|
+
n_drop = len(applied) - n_prom
|
|
700
|
+
print(f"review: {n_prom} promoted, {n_drop} dropped" + (" (dry-run)" if args.dry_run else ""))
|
|
701
|
+
return 0
|
|
702
|
+
|
|
703
|
+
|
|
534
704
|
def cmd_capture(args: argparse.Namespace) -> int:
|
|
535
705
|
"""Append a Source to an existing need (capture evidence)."""
|
|
536
706
|
cfg_path = Path(args.config)
|
|
@@ -544,6 +714,7 @@ def cmd_capture(args: argparse.Namespace) -> int:
|
|
|
544
714
|
args.need_id,
|
|
545
715
|
kind=args.kind,
|
|
546
716
|
who=args.who,
|
|
717
|
+
role=args.role,
|
|
547
718
|
date=args.date,
|
|
548
719
|
quote=args.quote,
|
|
549
720
|
locator=args.locator,
|
|
@@ -573,7 +744,9 @@ def _repo_row(config_path: str) -> RepoRow:
|
|
|
573
744
|
name = p.parent.name or str(p)
|
|
574
745
|
try:
|
|
575
746
|
cfg = Config.load(p)
|
|
576
|
-
|
|
747
|
+
# Drafts (#60) and withdrawn needs (#74) are off the counted board, same as `status`/`plan`,
|
|
748
|
+
# so a repo's portfolio line never inflates with staged ideas.
|
|
749
|
+
needs = board_needs(list(load(cfg).needs), include_drafts=False)
|
|
577
750
|
except (ConfigError, LoaderError, OSError) as exc:
|
|
578
751
|
return RepoRow(name, 0, 0, 0, 0, 0.0, {}, error=str(exc))
|
|
579
752
|
results = resolve(needs, cfg.adapters, cfg.source_adapters)
|
|
@@ -304,7 +304,12 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
304
304
|
bits = [s.kind.value]
|
|
305
305
|
bits += [
|
|
306
306
|
f"{k}={v}"
|
|
307
|
-
for k, v in (
|
|
307
|
+
for k, v in (
|
|
308
|
+
("who", s.who),
|
|
309
|
+
("role", s.role),
|
|
310
|
+
("date", s.date),
|
|
311
|
+
("locator", s.locator),
|
|
312
|
+
)
|
|
308
313
|
if v
|
|
309
314
|
]
|
|
310
315
|
line = " - " + " ".join(bits)
|
|
@@ -115,6 +115,7 @@ class Config:
|
|
|
115
115
|
default_factory=dict[int, tuple[str, str]]
|
|
116
116
|
) # [importance].levels — ordinal → (label, meaning); empty = no rubric declared (#59)
|
|
117
117
|
importance_revisit_days: int | None = None # [lint].importance_revisit_days (#59)
|
|
118
|
+
draft_max_age_days: int | None = None # [lint].draft_max_age_days — flag stale drafts (#60)
|
|
118
119
|
default_draft: bool = False # [add].default_draft — `add` stages as draft by default (#60)
|
|
119
120
|
callbacks: tuple[Callback, ...] = () # [[callbacks.hooks]] — lifecycle callbacks (#71)
|
|
120
121
|
callback_state_file: str = ".prodc/state.json" # [callbacks].state_file — the state ledger
|
|
@@ -269,6 +270,11 @@ class Config:
|
|
|
269
270
|
not isinstance(revisit, int) or isinstance(revisit, bool) or revisit < 1
|
|
270
271
|
):
|
|
271
272
|
raise ConfigError("[lint].importance_revisit_days must be a positive integer")
|
|
273
|
+
draft_max_age = lint_tbl.get("draft_max_age_days")
|
|
274
|
+
if draft_max_age is not None and (
|
|
275
|
+
not isinstance(draft_max_age, int) or isinstance(draft_max_age, bool) or draft_max_age < 1
|
|
276
|
+
):
|
|
277
|
+
raise ConfigError("[lint].draft_max_age_days must be a positive integer")
|
|
272
278
|
|
|
273
279
|
importance_levels: dict[int, tuple[str, str]] = {}
|
|
274
280
|
imp_tbl = _as_dict(raw.get("importance", {}), "[importance]")
|
|
@@ -303,6 +309,7 @@ class Config:
|
|
|
303
309
|
stale_after_days=stale,
|
|
304
310
|
importance_levels=importance_levels,
|
|
305
311
|
importance_revisit_days=revisit,
|
|
312
|
+
draft_max_age_days=draft_max_age,
|
|
306
313
|
default_draft=default_draft,
|
|
307
314
|
callbacks=callbacks,
|
|
308
315
|
callback_state_file=callback_state_file,
|
|
@@ -169,6 +169,7 @@ def _source(raw: object, where: str) -> Source:
|
|
|
169
169
|
return Source(
|
|
170
170
|
kind=kind,
|
|
171
171
|
who=_opt_str(tbl.get("who"), f"{where}.who"),
|
|
172
|
+
role=_opt_str(tbl.get("role"), f"{where}.role"),
|
|
172
173
|
date=_opt_str(tbl.get("date"), f"{where}.date"),
|
|
173
174
|
quote=_opt_str(tbl.get("quote"), f"{where}.quote"),
|
|
174
175
|
locator=_opt_str(tbl.get("locator"), f"{where}.locator"),
|
|
@@ -201,6 +202,7 @@ def _need(
|
|
|
201
202
|
release = _opt_str(tbl.get("release"), f"{where}.release")
|
|
202
203
|
product = _as_bool(tbl["product"], f"{where}.product") if "product" in tbl else True
|
|
203
204
|
draft = _as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False
|
|
205
|
+
draft_added = _opt_str(tbl.get("draft_added"), f"{where}.draft_added")
|
|
204
206
|
withdrawn = _withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn")
|
|
205
207
|
answer = _answer(tbl.get("answer"), f"{where}.answer")
|
|
206
208
|
|
|
@@ -260,6 +262,7 @@ def _need(
|
|
|
260
262
|
not_variant_of=not_variant_of,
|
|
261
263
|
importance_set_at=importance_set_at,
|
|
262
264
|
draft=draft,
|
|
265
|
+
draft_added=draft_added,
|
|
263
266
|
withdrawn=withdrawn,
|
|
264
267
|
answer=answer,
|
|
265
268
|
)
|
|
@@ -399,6 +402,7 @@ def load(config: Config) -> Model:
|
|
|
399
402
|
_check_ref(acc.needs, "goal", lambda n: n.goal, {g.id for g in acc.goals})
|
|
400
403
|
_check_ref(acc.needs, "release", lambda n: n.release, {r.id for r in acc.releases})
|
|
401
404
|
_check_ref(acc.needs, "role", lambda n: n.role, {p.id for p in acc.roles})
|
|
405
|
+
_check_source_roles(acc.needs, {p.id for p in acc.roles})
|
|
402
406
|
_check_argument(acc.argument)
|
|
403
407
|
return Model(
|
|
404
408
|
needs=tuple(acc.needs),
|
|
@@ -424,6 +428,21 @@ def _check_ref(
|
|
|
424
428
|
raise LoaderError(f"need {need.id!r}: {role} {ref!r} is not a known {role} id")
|
|
425
429
|
|
|
426
430
|
|
|
431
|
+
def _check_source_roles(needs: list[Need], known: set[str]) -> None:
|
|
432
|
+
"""Every ``source.role`` that is set must name a declared role id (like ``need.role``, #52).
|
|
433
|
+
|
|
434
|
+
Validated unconditionally: ``role`` is a new, opt-in field, so there is no migration reason to
|
|
435
|
+
be lax — a dangling role on a source is a typo the author owns, surfaced at load, not a silent
|
|
436
|
+
unknown that only shows as missing provenance later.
|
|
437
|
+
"""
|
|
438
|
+
for need in needs:
|
|
439
|
+
for i, s in enumerate(need.sources):
|
|
440
|
+
if s.role is not None and s.role not in known:
|
|
441
|
+
raise LoaderError(
|
|
442
|
+
f"need {need.id!r}: source[{i}] role {s.role!r} is not a known role id"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
427
446
|
def _check_argument(argument: list[ArgNode]) -> None:
|
|
428
447
|
"""Every ``supported_by``/``in_context_of`` link must name a known argument node.
|
|
429
448
|
|
|
@@ -74,6 +74,7 @@ class Source:
|
|
|
74
74
|
|
|
75
75
|
kind: SourceKind
|
|
76
76
|
who: str | None = None
|
|
77
|
+
role: str | None = None # id of the Role the `who` spoke as — whose signal this is (#52 follow-up)
|
|
77
78
|
date: str | None = None
|
|
78
79
|
quote: str | None = None
|
|
79
80
|
locator: str | None = None
|
|
@@ -198,6 +199,7 @@ class Need:
|
|
|
198
199
|
release: str | None = None # id of the Release slice this need ships in; None = unslotted
|
|
199
200
|
product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
|
|
200
201
|
draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
|
|
202
|
+
draft_added: str | None = None # ISO date this need was staged (#60); lets a lint age-gate it later
|
|
201
203
|
withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
|
|
202
204
|
answer: Answer | None = (
|
|
203
205
|
None # which way a settled need resolved (yes/no/inconclusive); None=open-ended
|
|
@@ -361,7 +361,7 @@ function renderDetail(n) {
|
|
|
361
361
|
let srcHtml;
|
|
362
362
|
if (!n.sources.length) srcHtml = '<span class="warn">no source — this need is an opinion</span>';
|
|
363
363
|
else srcHtml = n.sources.map(s => {
|
|
364
|
-
const head = `<span class="tag">${esc(s.kind)}</span>${s.who?" "+esc(s.who):""}${s.date?' <span class="meta">· '+esc(s.date)+'</span>':""}`;
|
|
364
|
+
const head = `<span class="tag">${esc(s.kind)}</span>${s.who?" "+esc(s.who):""}${s.role?' <span class="meta">('+esc(s.role)+')</span>':""}${s.date?' <span class="meta">· '+esc(s.date)+'</span>':""}`;
|
|
365
365
|
const q = s.quote ? `<div class="q">“${esc(s.quote)}”</div>` : "";
|
|
366
366
|
const loc = s.locator ? `<div class="meta">${esc(s.locator)}</div>` : "";
|
|
367
367
|
return `<div class="src">${head}${q}${loc}</div>`;
|
|
@@ -216,6 +216,22 @@ def _render_edit(old_text: str, doc: dict[str, object], need_id: str) -> str:
|
|
|
216
216
|
return "".join(lines[:start]) + block + "".join(lines[end:])
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
def _render_delete(old_text: str, doc: dict[str, object], need_id: str) -> str:
|
|
220
|
+
"""Re-render after removing a need, excising only its block to keep every other comment (#60).
|
|
221
|
+
|
|
222
|
+
The mirror of :func:`_render_edit`: find the need's span and drop it from the original text,
|
|
223
|
+
so the file header and the surrounding needs stay byte-for-byte. Falls back to a full re-dump
|
|
224
|
+
of ``doc`` (with the need already removed) when the file's shape is anything the splice isn't
|
|
225
|
+
sure of; the reload gate in :func:`_validated` guards correctness either way.
|
|
226
|
+
"""
|
|
227
|
+
lines = old_text.splitlines(keepends=True)
|
|
228
|
+
span = _need_block_span(lines, need_id)
|
|
229
|
+
if span is None:
|
|
230
|
+
return _dump(doc)
|
|
231
|
+
start, end = span[0], span[1]
|
|
232
|
+
return "".join(lines[:start]) + "".join(lines[end:])
|
|
233
|
+
|
|
234
|
+
|
|
219
235
|
def _read_doc(path: Path) -> tuple[dict[str, object], str]:
|
|
220
236
|
text = path.read_text(encoding="utf-8")
|
|
221
237
|
try:
|
|
@@ -372,6 +388,7 @@ def capture_source(
|
|
|
372
388
|
*,
|
|
373
389
|
kind: str,
|
|
374
390
|
who: str | None,
|
|
391
|
+
role: str | None = None,
|
|
375
392
|
date: str | None,
|
|
376
393
|
quote: str | None,
|
|
377
394
|
locator: str | None,
|
|
@@ -384,6 +401,8 @@ def capture_source(
|
|
|
384
401
|
Records evidence as given; the loader's soft hygiene checks (a primary source wants a who and a
|
|
385
402
|
quote-or-locator, a secondary source wants a locator) still apply on the next read.
|
|
386
403
|
``observed_at`` dates when the evidence was recorded, so the board can flag stale needs (#58).
|
|
404
|
+
``role`` records which declared role the ``who`` spoke as (validated against ``roles:`` on the
|
|
405
|
+
next load) — whose signal it is, orthogonal to the evidence tier (#52 follow-up).
|
|
387
406
|
"""
|
|
388
407
|
kind = _canonical_kind(kind)
|
|
389
408
|
path, _ = _locate(config, need_id)
|
|
@@ -393,6 +412,7 @@ def capture_source(
|
|
|
393
412
|
source: dict[str, object] = {"kind": kind}
|
|
394
413
|
for key, value in (
|
|
395
414
|
("who", who),
|
|
415
|
+
("role", role),
|
|
396
416
|
("date", date),
|
|
397
417
|
("quote", quote),
|
|
398
418
|
("locator", locator),
|
|
@@ -495,6 +515,31 @@ def reinstate(config: Config, need_id: str, *, expect_sha: str | None, dry_run:
|
|
|
495
515
|
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
496
516
|
|
|
497
517
|
|
|
518
|
+
def delete_need(
|
|
519
|
+
config: Config, need_id: str, *, expect_sha: str | None = None, dry_run: bool
|
|
520
|
+
) -> Edit:
|
|
521
|
+
"""Remove a need from its file entirely — the hard-delete write path (#60).
|
|
522
|
+
|
|
523
|
+
Unlike :func:`withdraw` (which keeps the need as a dated decision-against record), this erases
|
|
524
|
+
it. The reload gate makes it referentially safe: a need still pointed at by another's
|
|
525
|
+
``children``/``variant_of`` or a ``need:`` proof fails to load after the excision, so the write
|
|
526
|
+
is reverted and refused rather than leaving a dangling reference. Comment-safe (the #53 splice,
|
|
527
|
+
reversed): only the deleted need's block leaves the file.
|
|
528
|
+
"""
|
|
529
|
+
path, _ = _locate(config, need_id)
|
|
530
|
+
doc, old_text = _read_doc(path)
|
|
531
|
+
if expect_sha is not None and _sha256(old_text) != expect_sha:
|
|
532
|
+
raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
|
|
533
|
+
needs = _need_list(doc, path)
|
|
534
|
+
kept: list[object] = [
|
|
535
|
+
raw
|
|
536
|
+
for raw in needs
|
|
537
|
+
if not (isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id)
|
|
538
|
+
]
|
|
539
|
+
doc["needs"] = kept
|
|
540
|
+
return _validated(config, path, old_text, _render_delete(old_text, doc, need_id), dry_run)
|
|
541
|
+
|
|
542
|
+
|
|
498
543
|
def accept(
|
|
499
544
|
config: Config,
|
|
500
545
|
need_id: str,
|
|
@@ -603,6 +648,7 @@ def add_need(
|
|
|
603
648
|
}
|
|
604
649
|
if draft:
|
|
605
650
|
new_need["draft"] = True
|
|
651
|
+
new_need["draft_added"] = today # stamped so a lint age-gate can fire later (#60)
|
|
606
652
|
needs.append(new_need)
|
|
607
653
|
return _validated(config, target, old_text, _dump(doc), dry_run)
|
|
608
654
|
|
|
@@ -176,6 +176,18 @@ def test_portfolio_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> N
|
|
|
176
176
|
assert payload["totals"]["boards"] == 1 and payload["totals"]["total"] == 3
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
def test_portfolio_excludes_drafts(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
180
|
+
import json
|
|
181
|
+
|
|
182
|
+
# A draft (#60) must not inflate a repo's portfolio line — same board as status/plan.
|
|
183
|
+
cfg = _project(tmp_path / "alpha")
|
|
184
|
+
with (tmp_path / "alpha" / "needs" / "a.yaml").open("a", encoding="utf-8") as f:
|
|
185
|
+
f.write(" - id: DRAFT-1\n text: a staged idea\n draft: true\n")
|
|
186
|
+
main(["portfolio", str(cfg), "--json"])
|
|
187
|
+
payload = json.loads(capsys.readouterr().out)
|
|
188
|
+
assert payload["boards"][0]["total"] == 3 # still 3, the draft is off the counted board
|
|
189
|
+
|
|
190
|
+
|
|
179
191
|
def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
180
192
|
code = main(["portfolio", str(tmp_path / "nope.toml")])
|
|
181
193
|
capsys.readouterr()
|
|
@@ -127,3 +127,47 @@ def test_promote_via_set_draft_false(tmp_path: Path, capsys: pytest.CaptureFixtu
|
|
|
127
127
|
main(["status", "--config", str(cfg), "--json"])
|
|
128
128
|
payload = json.loads(capsys.readouterr().out)
|
|
129
129
|
assert {n["id"] for n in payload["needs"]} == {"N-1", "D-1"}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_AGE_NEEDS = """
|
|
133
|
+
needs:
|
|
134
|
+
- id: N-1
|
|
135
|
+
text: user can see a thing
|
|
136
|
+
- id: OLD-1
|
|
137
|
+
text: a long-forgotten draft
|
|
138
|
+
draft: true
|
|
139
|
+
draft_added: 2020-01-01
|
|
140
|
+
- id: UNDATED-1
|
|
141
|
+
text: a draft with no staged date
|
|
142
|
+
draft: true
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_lint_flags_stale_draft(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
147
|
+
from prodc.cli import main
|
|
148
|
+
|
|
149
|
+
toml = _TOML + "\n[lint]\ndraft_max_age_days = 30\n"
|
|
150
|
+
cfg = _project(tmp_path, toml=toml, needs=_AGE_NEEDS)
|
|
151
|
+
code = main(["lint", "--config", str(cfg)])
|
|
152
|
+
out = capsys.readouterr().out
|
|
153
|
+
assert code == 0 # an old draft is a warning, not a hard error
|
|
154
|
+
assert "OLD-1: draft staged" in out
|
|
155
|
+
assert "UNDATED-1: draft with no staged date" in out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_lint_no_draft_gate_without_knob(
|
|
159
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
160
|
+
) -> None:
|
|
161
|
+
from prodc.cli import main
|
|
162
|
+
|
|
163
|
+
cfg = _project(tmp_path, needs=_AGE_NEEDS) # no [lint].draft_max_age_days
|
|
164
|
+
main(["lint", "--config", str(cfg)])
|
|
165
|
+
assert "draft staged" not in capsys.readouterr().out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_config_rejects_bad_draft_max_age_days(tmp_path: Path) -> None:
|
|
169
|
+
from prodc.config import ConfigError
|
|
170
|
+
|
|
171
|
+
toml = _TOML + "\n[lint]\ndraft_max_age_days = 0\n"
|
|
172
|
+
with pytest.raises(ConfigError, match="draft_max_age_days"):
|
|
173
|
+
Config.load(_project(tmp_path, toml=toml))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""`prodc review`: triage staged drafts — list, promote, drop (#60 follow-up)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from prodc.cli import main
|
|
11
|
+
from prodc.config import Config
|
|
12
|
+
from prodc.loader import load
|
|
13
|
+
|
|
14
|
+
_TOML = """
|
|
15
|
+
[prodc]
|
|
16
|
+
needs = ["needs/*.yaml"]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_NEEDS = """\
|
|
20
|
+
# header comment, must survive
|
|
21
|
+
needs:
|
|
22
|
+
- id: N-1
|
|
23
|
+
text: the owner can see a delivered thing
|
|
24
|
+
proofs: ["x:1"]
|
|
25
|
+
- id: D-1
|
|
26
|
+
text: the owner can see a delivered thing
|
|
27
|
+
draft: true
|
|
28
|
+
draft_added: 2026-10-10
|
|
29
|
+
- id: D-2
|
|
30
|
+
text: a totally unrelated vacuum request
|
|
31
|
+
draft: true
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _project(tmp_path: Path, needs: str = _NEEDS) -> Path:
|
|
36
|
+
(tmp_path / "needs").mkdir(parents=True, exist_ok=True)
|
|
37
|
+
(tmp_path / "prodc.toml").write_text(_TOML, encoding="utf-8")
|
|
38
|
+
(tmp_path / "needs" / "a.yaml").write_text(needs, encoding="utf-8")
|
|
39
|
+
return tmp_path / "prodc.toml"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_review_lists_only_drafts(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
43
|
+
main(["review", "--config", str(_project(tmp_path)), "--json"])
|
|
44
|
+
payload = json.loads(capsys.readouterr().out)
|
|
45
|
+
ids = {d["id"] for d in payload["drafts"]}
|
|
46
|
+
assert ids == {"D-1", "D-2"} # N-1 is on the board, not a draft
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_review_list_carries_staged_date_and_dup(
|
|
50
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
51
|
+
) -> None:
|
|
52
|
+
main(["review", "--config", str(_project(tmp_path)), "--json"])
|
|
53
|
+
rows = {d["id"]: d for d in json.loads(capsys.readouterr().out)["drafts"]}
|
|
54
|
+
assert rows["D-1"]["staged"] == "2026-10-10"
|
|
55
|
+
# D-1 is near-identical to N-1 → a duplicate hit; D-2 is unrelated → none
|
|
56
|
+
assert rows["D-1"]["duplicate"] is not None
|
|
57
|
+
assert rows["D-1"]["duplicate"]["id"] == "N-1"
|
|
58
|
+
assert rows["D-2"]["duplicate"] is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_review_promote_sets_draft_false(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
62
|
+
cfg = _project(tmp_path)
|
|
63
|
+
code = main(["review", "--promote", "D-1", "--config", str(cfg)])
|
|
64
|
+
assert code == 0
|
|
65
|
+
capsys.readouterr()
|
|
66
|
+
by_id = {n.id: n for n in load(Config.load(cfg)).needs}
|
|
67
|
+
assert by_id["D-1"].draft is False and by_id["D-2"].draft is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_review_drop_deletes_the_need(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
71
|
+
cfg = _project(tmp_path)
|
|
72
|
+
code = main(["review", "--drop", "D-2", "--config", str(cfg)])
|
|
73
|
+
assert code == 0
|
|
74
|
+
capsys.readouterr()
|
|
75
|
+
ids = {n.id for n in load(Config.load(cfg)).needs}
|
|
76
|
+
assert ids == {"N-1", "D-1"} # D-2 is gone from the file
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_review_promote_all_except_dropped(
|
|
80
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
81
|
+
) -> None:
|
|
82
|
+
cfg = _project(tmp_path)
|
|
83
|
+
code = main(["review", "--promote-all", "--drop", "D-2", "--config", str(cfg)])
|
|
84
|
+
assert code == 0
|
|
85
|
+
capsys.readouterr()
|
|
86
|
+
by_id = {n.id: n for n in load(Config.load(cfg)).needs}
|
|
87
|
+
assert "D-2" not in by_id # dropped
|
|
88
|
+
assert by_id["D-1"].draft is False # promoted
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_review_refuses_non_draft(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
92
|
+
cfg = _project(tmp_path)
|
|
93
|
+
code = main(["review", "--drop", "N-1", "--config", str(cfg)])
|
|
94
|
+
assert code == 2
|
|
95
|
+
assert "not a staged draft" in capsys.readouterr().err
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_review_refuses_id_in_both_lists(
|
|
99
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
100
|
+
) -> None:
|
|
101
|
+
cfg = _project(tmp_path)
|
|
102
|
+
code = main(["review", "--promote", "D-1", "--drop", "D-1", "--config", str(cfg)])
|
|
103
|
+
assert code == 2
|
|
104
|
+
assert "both --promote and --drop" in capsys.readouterr().err
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_review_dry_run_writes_nothing(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
108
|
+
cfg = _project(tmp_path)
|
|
109
|
+
before = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
|
|
110
|
+
code = main(["review", "--promote-all", "--dry-run", "--config", str(cfg)])
|
|
111
|
+
assert code == 0
|
|
112
|
+
assert (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8") == before
|
|
113
|
+
assert "dry-run" in capsys.readouterr().out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_review_preserves_header_comment(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
117
|
+
cfg = _project(tmp_path)
|
|
118
|
+
main(["review", "--drop", "D-2", "--config", str(cfg)])
|
|
119
|
+
capsys.readouterr()
|
|
120
|
+
text = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
|
|
121
|
+
assert "# header comment, must survive" in text
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Source.role (#52 follow-up): which declared role vouched for the evidence, + label deprecation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from prodc.board import warrant
|
|
11
|
+
from prodc.config import Config
|
|
12
|
+
from prodc.loader import LoaderError, load
|
|
13
|
+
from prodc.model import Need, Source, SourceKind
|
|
14
|
+
from prodc.writer import capture_source
|
|
15
|
+
|
|
16
|
+
_TOML = "[prodc]\nneeds = [\"needs/*.yaml\"]\n"
|
|
17
|
+
|
|
18
|
+
_NEEDS = """
|
|
19
|
+
roles:
|
|
20
|
+
- id: owner
|
|
21
|
+
text: owns the roastery
|
|
22
|
+
needs:
|
|
23
|
+
- id: N-1
|
|
24
|
+
text: the owner can see orders
|
|
25
|
+
sources:
|
|
26
|
+
- kind: primary
|
|
27
|
+
who: Jordan
|
|
28
|
+
role: owner
|
|
29
|
+
date: 2026-01-01
|
|
30
|
+
quote: "I need this"
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _project(tmp_path: Path, needs: str = _NEEDS, toml: str = _TOML) -> Config:
|
|
35
|
+
(tmp_path / "needs").mkdir(parents=True, exist_ok=True)
|
|
36
|
+
(tmp_path / "prodc.toml").write_text(toml, encoding="utf-8")
|
|
37
|
+
(tmp_path / "needs" / "a.yaml").write_text(needs, encoding="utf-8")
|
|
38
|
+
return Config.load(tmp_path / "prodc.toml")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_loader_parses_source_role(tmp_path: Path) -> None:
|
|
42
|
+
need = load(_project(tmp_path)).needs[0]
|
|
43
|
+
assert need.sources[0].role == "owner"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_source_role_orthogonal_to_kind() -> None:
|
|
47
|
+
# role is provenance, not a strength tier — it does not change ranking
|
|
48
|
+
s = Source(kind=SourceKind.PRIMARY, who="J", role="owner")
|
|
49
|
+
n = Need(id="N", text="t", sources=(s,))
|
|
50
|
+
assert n.strongest_kind is SourceKind.PRIMARY # role plays no part
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_warrant_shows_role_on_primary(tmp_path: Path) -> None:
|
|
54
|
+
need = load(_project(tmp_path)).needs[0]
|
|
55
|
+
assert warrant(need) == "primary:Jordan (owner)"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_warrant_without_role_is_unchanged() -> None:
|
|
59
|
+
n = Need(id="N", text="t", sources=(Source(kind=SourceKind.PRIMARY, who="Jo"),))
|
|
60
|
+
assert warrant(n) == "primary:Jo"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_dangling_source_role_rejected(tmp_path: Path) -> None:
|
|
64
|
+
needs = _NEEDS.replace("role: owner", "role: nobody")
|
|
65
|
+
with pytest.raises(LoaderError, match="source\\[0\\] role 'nobody' is not a known role id"):
|
|
66
|
+
load(_project(tmp_path, needs=needs))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_show_json_carries_source_role(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
70
|
+
from prodc.cli import main
|
|
71
|
+
|
|
72
|
+
_project(tmp_path)
|
|
73
|
+
main(["show", "N-1", "--config", str(tmp_path / "prodc.toml"), "--json"])
|
|
74
|
+
payload = json.loads(capsys.readouterr().out)
|
|
75
|
+
assert payload["sources"][0]["role"] == "owner"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_capture_writes_source_role(tmp_path: Path) -> None:
|
|
79
|
+
cfg = _project(
|
|
80
|
+
tmp_path,
|
|
81
|
+
needs="roles:\n - id: owner\n text: owns it\nneeds:\n - id: N-1\n text: t\n",
|
|
82
|
+
)
|
|
83
|
+
capture_source(
|
|
84
|
+
cfg,
|
|
85
|
+
"N-1",
|
|
86
|
+
kind="primary",
|
|
87
|
+
who="Jordan",
|
|
88
|
+
role="owner",
|
|
89
|
+
date="2026-02-02",
|
|
90
|
+
quote="I said so",
|
|
91
|
+
locator=None,
|
|
92
|
+
expect_sha=None,
|
|
93
|
+
dry_run=False,
|
|
94
|
+
)
|
|
95
|
+
need = load(cfg).needs[0]
|
|
96
|
+
assert need.sources[-1].role == "owner" and need.sources[-1].who == "Jordan"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_capture_dangling_role_rejected_by_gate(tmp_path: Path) -> None:
|
|
100
|
+
cfg = _project(
|
|
101
|
+
tmp_path,
|
|
102
|
+
needs="roles:\n - id: owner\n text: owns it\nneeds:\n - id: N-1\n text: t\n",
|
|
103
|
+
)
|
|
104
|
+
with pytest.raises(Exception, match="not a known role id"):
|
|
105
|
+
capture_source(
|
|
106
|
+
cfg,
|
|
107
|
+
"N-1",
|
|
108
|
+
kind="primary",
|
|
109
|
+
who="Jordan",
|
|
110
|
+
role="ghost",
|
|
111
|
+
date="2026-02-02",
|
|
112
|
+
quote="q",
|
|
113
|
+
locator=None,
|
|
114
|
+
expect_sha=None,
|
|
115
|
+
dry_run=False,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_lint_warns_on_deprecated_label(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
120
|
+
from prodc.cli import main
|
|
121
|
+
|
|
122
|
+
needs = 'who: jordan\nlabel: "design partner"\nneeds:\n - id: N-1\n text: t\n'
|
|
123
|
+
_project(tmp_path, needs=needs)
|
|
124
|
+
main(["lint", "--config", str(tmp_path / "prodc.toml")])
|
|
125
|
+
out = capsys.readouterr().out
|
|
126
|
+
assert "'label'" in out and "deprecated" in out
|
|
@@ -9,7 +9,14 @@ import pytest
|
|
|
9
9
|
|
|
10
10
|
from prodc.config import Config
|
|
11
11
|
from prodc.loader import load
|
|
12
|
-
from prodc.writer import
|
|
12
|
+
from prodc.writer import (
|
|
13
|
+
WriteError,
|
|
14
|
+
add_need,
|
|
15
|
+
add_role,
|
|
16
|
+
capture_source,
|
|
17
|
+
delete_need,
|
|
18
|
+
set_field,
|
|
19
|
+
)
|
|
13
20
|
|
|
14
21
|
|
|
15
22
|
def _proj(tmp_path: Path, body: str) -> Config:
|
|
@@ -254,3 +261,61 @@ def test_add_role_dry_run_leaves_no_file(tmp_path: Path) -> None:
|
|
|
254
261
|
edit = add_role(cfg, "owner", "owns it", file="needs/roles.yaml", dry_run=True)
|
|
255
262
|
assert "owner" in edit.diff
|
|
256
263
|
assert not (cfg.root / "needs" / "roles.yaml").exists() # dry-run wrote nothing
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_delete_need_removes_the_block_keeping_others(tmp_path: Path) -> None:
|
|
267
|
+
cfg = _proj(
|
|
268
|
+
tmp_path,
|
|
269
|
+
"# keep me\nneeds:\n - id: N-1\n text: one\n - id: N-2\n text: two\n",
|
|
270
|
+
)
|
|
271
|
+
delete_need(cfg, "N-1", dry_run=False)
|
|
272
|
+
text = _a_yaml(cfg)
|
|
273
|
+
assert {n.id for n in load(cfg).needs} == {"N-2"}
|
|
274
|
+
assert "# keep me" in text # header comment survives the splice
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
|
|
278
|
+
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
|
|
279
|
+
with pytest.raises(WriteError, match="no need 'N-9'"):
|
|
280
|
+
delete_need(cfg, "N-9", dry_run=False)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_delete_need_dangling_reference_reverts(tmp_path: Path) -> None:
|
|
284
|
+
# N-2 is a child of flow N-1; deleting N-2 would dangle the reference, so the reload gate
|
|
285
|
+
# must reject the write and restore the file.
|
|
286
|
+
body = (
|
|
287
|
+
"needs:\n"
|
|
288
|
+
" - id: N-1\n text: a flow\n children: [N-2]\n"
|
|
289
|
+
" - id: N-2\n text: a step\n proofs: ['x:1']\n"
|
|
290
|
+
)
|
|
291
|
+
cfg = _proj(tmp_path, body)
|
|
292
|
+
before = _a_yaml(cfg)
|
|
293
|
+
with pytest.raises(WriteError, match="edit rejected"):
|
|
294
|
+
delete_need(cfg, "N-2", dry_run=False)
|
|
295
|
+
assert _a_yaml(cfg) == before # reverted
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_delete_need_dry_run_leaves_file(tmp_path: Path) -> None:
|
|
299
|
+
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: one\n - id: N-2\n text: two\n")
|
|
300
|
+
before = _a_yaml(cfg)
|
|
301
|
+
edit = delete_need(cfg, "N-1", dry_run=True)
|
|
302
|
+
assert _a_yaml(cfg) == before # nothing written
|
|
303
|
+
assert "N-1" in edit.diff and edit.diff.startswith("---")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_add_draft_stamps_draft_added(tmp_path: Path) -> None:
|
|
307
|
+
cfg = _proj(tmp_path, "needs: []\n")
|
|
308
|
+
add_need(
|
|
309
|
+
cfg,
|
|
310
|
+
"D-1",
|
|
311
|
+
"a staged idea",
|
|
312
|
+
who="agent",
|
|
313
|
+
kind="assumption",
|
|
314
|
+
file="needs/a.yaml",
|
|
315
|
+
draft=True,
|
|
316
|
+
expect_sha=None,
|
|
317
|
+
dry_run=False,
|
|
318
|
+
)
|
|
319
|
+
need = {n.id: n for n in load(cfg).needs}["D-1"]
|
|
320
|
+
assert need.draft is True
|
|
321
|
+
assert need.draft_added == datetime.date.today().isoformat()
|
|
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
|