prodc 0.3.75__tar.gz → 0.3.77__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.75 → prodc-0.3.77}/PKG-INFO +1 -1
- {prodc-0.3.75 → prodc-0.3.77}/pyproject.toml +1 -1
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/agent.md +2 -1
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/board.py +10 -2
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/app.py +13 -1
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/commands.py +100 -2
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/render.py +3 -1
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/loader.py +13 -4
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/model.py +18 -2
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/status.py +56 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/writer.py +48 -3
- prodc-0.3.77/tests/test_relink.py +103 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_withdrawn.py +87 -1
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_writer.py +22 -0
- {prodc-0.3.75 → prodc-0.3.77}/.gitignore +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/CHANGELOG.md +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/LICENSE +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/README.md +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/__init__.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/adapters.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/config.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/dedup.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/py.typed +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/report.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/src/prodc/report_html.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_adapters.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_answer.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_callbacks.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_citation.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_cli.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_command_hook.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_dedup.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_description.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_drafts.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_evalgate.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_importance.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_loader.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_models.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_needref.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_report.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_report_html.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_review.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_roles.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_source_role.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_sources.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_staleness.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_status.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_suggest.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_variants.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/tests/test_who_variants.py +0 -0
- {prodc-0.3.75 → prodc-0.3.77}/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.77
|
|
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
|
|
@@ -75,10 +75,11 @@ Read first, then act, then check.
|
|
|
75
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) |
|
|
76
76
|
| set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
|
|
77
77
|
| propose / attach a proof for a HOLE | `prodc suggest <id> [--apply] [--dry-run]` |
|
|
78
|
+
| relink a BROKEN proof (renamed test) | `prodc suggest --relink [<id>] [--apply] [--dry-run]` — proposes the nearest live inventory entry to replace the broken locator |
|
|
78
79
|
| link a recurring want to a shared baseline | `prodc recurrence --suggest --apply --baseline-id <id> --file needs/<f>.yaml` |
|
|
79
80
|
| record a human/AI review of the warrant | `prodc attest <id> --by <who> --date <ISO> [--ai]` |
|
|
80
81
|
| record a human acceptance pass | `prodc accept <id> --who <person> --date <ISO>` |
|
|
81
|
-
| decide against a need (won't-do) | `prodc withdraw <id> --reason "…"` — off the board + out of coverage; `prodc reinstate <id>` puts it back |
|
|
82
|
+
| decide against a need (won't-do) | `prodc withdraw <id> --reason "…" [--kind out_of_scope\|duplicate]` — off the board + out of coverage (use `out_of_scope` for a real need that isn't this product); `prodc reinstate <id>` puts it back |
|
|
82
83
|
| hand a human a readable status file | `prodc report -o status.md` (markdown; `--format html` for an interactive viewer; `--client ID` to scope) |
|
|
83
84
|
| 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) |
|
|
84
85
|
| gate in CI | `prodc lint --strict` |
|
|
@@ -102,7 +102,11 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
102
102
|
"claim": need.claim,
|
|
103
103
|
"answer": need.answer.value if need.answer else None,
|
|
104
104
|
"withdrawn": (
|
|
105
|
-
{
|
|
105
|
+
{
|
|
106
|
+
"reason": need.withdrawn.reason,
|
|
107
|
+
"date": need.withdrawn.date,
|
|
108
|
+
"kind": need.withdrawn.kind.value,
|
|
109
|
+
}
|
|
106
110
|
if need.withdrawn
|
|
107
111
|
else None
|
|
108
112
|
),
|
|
@@ -150,7 +154,11 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
150
154
|
"draft": need.draft,
|
|
151
155
|
"answer": need.answer.value if need.answer else None,
|
|
152
156
|
"withdrawn": (
|
|
153
|
-
{
|
|
157
|
+
{
|
|
158
|
+
"reason": need.withdrawn.reason,
|
|
159
|
+
"date": need.withdrawn.date,
|
|
160
|
+
"kind": need.withdrawn.kind.value,
|
|
161
|
+
}
|
|
154
162
|
if need.withdrawn
|
|
155
163
|
else None
|
|
156
164
|
),
|
|
@@ -300,6 +300,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
300
300
|
wd.add_argument(
|
|
301
301
|
"--reason", required=True, help="why it was decided against (kept on the record)"
|
|
302
302
|
)
|
|
303
|
+
wd.add_argument(
|
|
304
|
+
"--kind",
|
|
305
|
+
choices=["wont_do", "out_of_scope", "duplicate"],
|
|
306
|
+
default="wont_do",
|
|
307
|
+
help="the decision's kind: wont_do (default), out_of_scope (real but not this product), "
|
|
308
|
+
"or duplicate (#74)",
|
|
309
|
+
)
|
|
303
310
|
wd.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-10; default: today)")
|
|
304
311
|
wd.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
305
312
|
wd.add_argument("--json", action="store_true", help="machine-readable output")
|
|
@@ -433,10 +440,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
433
440
|
sg.add_argument(
|
|
434
441
|
"--kind", default=None, metavar="ADAPTER", help="only suggest from this adapter name"
|
|
435
442
|
)
|
|
443
|
+
sg.add_argument(
|
|
444
|
+
"--relink",
|
|
445
|
+
action="store_true",
|
|
446
|
+
help="BROKEN-need mode: propose a live inventory entry to replace each broken locator (#54)",
|
|
447
|
+
)
|
|
436
448
|
sg.add_argument(
|
|
437
449
|
"--apply",
|
|
438
450
|
action="store_true",
|
|
439
|
-
help="write the best suggestion per need as a proof (#54)
|
|
451
|
+
help="write the best suggestion per need as a proof (relink: replace the broken one) (#54)",
|
|
440
452
|
)
|
|
441
453
|
sg.add_argument(
|
|
442
454
|
"--dry-run",
|
|
@@ -33,11 +33,13 @@ from ..report import render_markdown
|
|
|
33
33
|
from ..report_html import render_html
|
|
34
34
|
from ..status import (
|
|
35
35
|
ProofSuggestion,
|
|
36
|
+
RelinkSuggestion,
|
|
36
37
|
applicable_needs,
|
|
37
38
|
argument_defects,
|
|
38
39
|
evidence_age_days,
|
|
39
40
|
proof_suggestions,
|
|
40
41
|
recurrence,
|
|
42
|
+
relink_suggestions,
|
|
41
43
|
resolve,
|
|
42
44
|
summarize,
|
|
43
45
|
)
|
|
@@ -53,6 +55,7 @@ from ..writer import (
|
|
|
53
55
|
delete_need,
|
|
54
56
|
link_variant_group,
|
|
55
57
|
reinstate,
|
|
58
|
+
relink_proof,
|
|
56
59
|
set_field,
|
|
57
60
|
set_importance,
|
|
58
61
|
withdraw,
|
|
@@ -260,6 +263,14 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
260
263
|
# orphans) until promoted with `set <id> draft=false`, unless --include-drafts is passed.
|
|
261
264
|
draft_count = sum(1 for n in needs if n.draft and not n.is_withdrawn)
|
|
262
265
|
withdrawn_count = sum(1 for n in needs if n.is_withdrawn)
|
|
266
|
+
# Break withdrawals down by kind (#74 follow-up), so "declined but valid" (out_of_scope) is
|
|
267
|
+
# legible on the board apart from plain won't-do; wont_do is the implicit default, not listed.
|
|
268
|
+
withdrawn_kinds: dict[str, int] = {}
|
|
269
|
+
for n in needs:
|
|
270
|
+
if n.withdrawn and n.withdrawn.kind.value != "wont_do":
|
|
271
|
+
withdrawn_kinds[n.withdrawn.kind.value] = (
|
|
272
|
+
withdrawn_kinds.get(n.withdrawn.kind.value, 0) + 1
|
|
273
|
+
)
|
|
263
274
|
needs = board_needs(needs, include_drafts=args.include_drafts)
|
|
264
275
|
# Scoped coverage (#49): restrict the board + coverage to a subset, so the number people
|
|
265
276
|
# act on (this client, this goal) isn't diluted by the whole mixed board.
|
|
@@ -315,7 +326,9 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
315
326
|
if draft_count and not args.include_drafts:
|
|
316
327
|
print(f"drafts: {draft_count} staged (hidden — --include-drafts to show) (#60)")
|
|
317
328
|
if withdrawn_count:
|
|
318
|
-
|
|
329
|
+
extra = ", ".join(f"{c} {k}" for k, c in sorted(withdrawn_kinds.items()))
|
|
330
|
+
suffix = f" — {extra}" if extra else ""
|
|
331
|
+
print(f"withdrawn: {withdrawn_count} (decided against){suffix} (#74)")
|
|
319
332
|
visible = [r for r in results if r.need.product]
|
|
320
333
|
if args.only_must:
|
|
321
334
|
visible = [r for r in visible if r.need.must]
|
|
@@ -546,6 +559,7 @@ def cmd_withdraw(args: argparse.Namespace) -> int:
|
|
|
546
559
|
args.need_id,
|
|
547
560
|
reason=args.reason,
|
|
548
561
|
date=args.date or date.today().isoformat(),
|
|
562
|
+
kind=args.kind,
|
|
549
563
|
expect_sha=args.expect_sha,
|
|
550
564
|
dry_run=args.dry_run,
|
|
551
565
|
)
|
|
@@ -954,7 +968,7 @@ def cmd_agent(args: argparse.Namespace) -> int:
|
|
|
954
968
|
|
|
955
969
|
|
|
956
970
|
def cmd_suggest(args: argparse.Namespace) -> int:
|
|
957
|
-
"""Propose candidate proof locators for HOLE needs from the
|
|
971
|
+
"""Propose candidate proof locators for HOLE needs (or relink BROKEN ones) from the inventories (#54)."""
|
|
958
972
|
loaded = load_or_report(args)
|
|
959
973
|
if loaded is None:
|
|
960
974
|
return 2
|
|
@@ -966,6 +980,8 @@ def cmd_suggest(args: argparse.Namespace) -> int:
|
|
|
966
980
|
if not results:
|
|
967
981
|
print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
|
|
968
982
|
return 2
|
|
983
|
+
if args.relink:
|
|
984
|
+
return _cmd_relink(cfg, results, args)
|
|
969
985
|
suggestions = proof_suggestions(
|
|
970
986
|
results, cfg.adapters, min_score=args.min_score, top=args.top, kind=args.kind
|
|
971
987
|
)
|
|
@@ -1044,6 +1060,88 @@ def _apply_suggestions(
|
|
|
1044
1060
|
return 0
|
|
1045
1061
|
|
|
1046
1062
|
|
|
1063
|
+
def _cmd_relink(cfg: Config, results: list[NeedResult], args: argparse.Namespace) -> int:
|
|
1064
|
+
"""Propose (or --apply) a live inventory entry to replace each BROKEN proof locator (#54)."""
|
|
1065
|
+
suggestions = relink_suggestions(
|
|
1066
|
+
results, cfg.adapters, min_score=args.min_score, top=args.top, kind=args.kind
|
|
1067
|
+
)
|
|
1068
|
+
if args.apply:
|
|
1069
|
+
return _apply_relinks(cfg, suggestions, args)
|
|
1070
|
+
if args.json:
|
|
1071
|
+
print(
|
|
1072
|
+
json.dumps(
|
|
1073
|
+
[
|
|
1074
|
+
{
|
|
1075
|
+
"need": s.need.id,
|
|
1076
|
+
"adapter": s.adapter,
|
|
1077
|
+
"broken": s.broken,
|
|
1078
|
+
"locator": s.locator,
|
|
1079
|
+
"score": round(s.score, 4),
|
|
1080
|
+
}
|
|
1081
|
+
for s in suggestions
|
|
1082
|
+
],
|
|
1083
|
+
indent=2,
|
|
1084
|
+
)
|
|
1085
|
+
)
|
|
1086
|
+
else:
|
|
1087
|
+
last = None
|
|
1088
|
+
for s in suggestions:
|
|
1089
|
+
if s.need.id != last:
|
|
1090
|
+
print(f"{s.need.id}: {s.need.text}")
|
|
1091
|
+
last = s.need.id
|
|
1092
|
+
print(f" {s.score:.2f} {s.broken} → {s.locator}")
|
|
1093
|
+
print(f"suggest --relink: {len(suggestions)} candidate(s) at min-score {args.min_score:.2f}")
|
|
1094
|
+
return 0
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _apply_relinks(
|
|
1098
|
+
cfg: Config, suggestions: list[RelinkSuggestion], args: argparse.Namespace
|
|
1099
|
+
) -> int:
|
|
1100
|
+
"""Apply the best relink per broken locator, printing each need's diff (#54 relink).
|
|
1101
|
+
|
|
1102
|
+
Keyed by ``(need, broken locator)`` so a need with two broken proofs relinks both to their own
|
|
1103
|
+
best candidate. Each is one reload-gated single-file edit (:func:`relink_proof`), applied in a
|
|
1104
|
+
stable order; a rejected write aborts with a non-zero exit (earlier writes stand).
|
|
1105
|
+
"""
|
|
1106
|
+
best: dict[tuple[str, str], RelinkSuggestion] = {}
|
|
1107
|
+
for s in suggestions:
|
|
1108
|
+
best.setdefault((s.need.id, s.broken), s)
|
|
1109
|
+
applied: list[tuple[RelinkSuggestion, Edit]] = []
|
|
1110
|
+
for key in sorted(best):
|
|
1111
|
+
s = best[key]
|
|
1112
|
+
try:
|
|
1113
|
+
edit = relink_proof(cfg, s.need.id, s.broken, s.locator, dry_run=args.dry_run)
|
|
1114
|
+
except WriteError as exc:
|
|
1115
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
1116
|
+
return 2
|
|
1117
|
+
applied.append((s, edit))
|
|
1118
|
+
if args.json:
|
|
1119
|
+
print(
|
|
1120
|
+
json.dumps(
|
|
1121
|
+
[
|
|
1122
|
+
{
|
|
1123
|
+
"need": s.need.id,
|
|
1124
|
+
"broken": s.broken,
|
|
1125
|
+
"locator": s.locator,
|
|
1126
|
+
"score": round(s.score, 4),
|
|
1127
|
+
"file": edit.path.name,
|
|
1128
|
+
"dry_run": args.dry_run,
|
|
1129
|
+
"diff": edit.diff,
|
|
1130
|
+
}
|
|
1131
|
+
for s, edit in applied
|
|
1132
|
+
],
|
|
1133
|
+
indent=2,
|
|
1134
|
+
)
|
|
1135
|
+
)
|
|
1136
|
+
else:
|
|
1137
|
+
verb = "would relink" if args.dry_run else "relinked"
|
|
1138
|
+
for s, edit in applied:
|
|
1139
|
+
print(f"{verb} {s.broken} → {s.locator} on {s.need.id} ({s.score:.2f}) in {edit.path.name}")
|
|
1140
|
+
print(edit.diff, end="")
|
|
1141
|
+
print(f"suggest --relink --apply: {verb} {len(applied)} locator(s)")
|
|
1142
|
+
return 0
|
|
1143
|
+
|
|
1144
|
+
|
|
1047
1145
|
def cmd_recurrence(args: argparse.Namespace) -> int:
|
|
1048
1146
|
"""Show baselines shared across clients; `--suggest` proposes unlinked variant groups (#56)."""
|
|
1049
1147
|
loaded = load_or_report(args)
|
|
@@ -276,7 +276,9 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
276
276
|
if need.answer:
|
|
277
277
|
lines.append(f" answer: {need.answer.value}")
|
|
278
278
|
if need.withdrawn:
|
|
279
|
-
|
|
279
|
+
w = need.withdrawn
|
|
280
|
+
tag = "" if w.kind.value == "wont_do" else f" [{w.kind.value}]"
|
|
281
|
+
lines.append(f" withdrawn{tag}: {w.reason} ({w.date})")
|
|
280
282
|
if need.description:
|
|
281
283
|
lines.append(f" description: {need.description}")
|
|
282
284
|
if need.importance is not None:
|
|
@@ -30,6 +30,7 @@ from .model import (
|
|
|
30
30
|
Source,
|
|
31
31
|
SourceKind,
|
|
32
32
|
Withdrawal,
|
|
33
|
+
WithdrawalKind,
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
|
|
@@ -130,10 +131,12 @@ def _answer(raw: object, where: str) -> Answer | None:
|
|
|
130
131
|
|
|
131
132
|
|
|
132
133
|
def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
133
|
-
"""Parse ``withdrawn: {reason, date}`` into a :class:`Withdrawal` (#74).
|
|
134
|
+
"""Parse ``withdrawn: {reason, date, kind?}`` into a :class:`Withdrawal` (#74).
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
and when, the same way an attestation needs who and when.
|
|
136
|
+
``reason`` and ``date`` are required when the key is present — a decision against a need must
|
|
137
|
+
say why and when, the same way an attestation needs who and when. ``kind`` is optional and
|
|
138
|
+
defaults to ``wont_do``; it is validated with the same ``Enum(name) except ValueError`` pattern
|
|
139
|
+
as ``SourceKind``, so an unknown kind is a :class:`LoaderError` the author owns.
|
|
137
140
|
"""
|
|
138
141
|
if raw is None:
|
|
139
142
|
return None
|
|
@@ -142,7 +145,13 @@ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
|
142
145
|
date = _opt_str(tbl.get("date"), f"{where}.date")
|
|
143
146
|
if not reason or not date:
|
|
144
147
|
raise LoaderError(f"{where}: a withdrawal needs both 'reason' and 'date'")
|
|
145
|
-
|
|
148
|
+
kind_name = _opt_str(tbl.get("kind"), f"{where}.kind") or WithdrawalKind.WONT_DO.value
|
|
149
|
+
try:
|
|
150
|
+
kind = WithdrawalKind(kind_name)
|
|
151
|
+
except ValueError:
|
|
152
|
+
kinds = "|".join(k.value for k in WithdrawalKind)
|
|
153
|
+
raise LoaderError(f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})") from None
|
|
154
|
+
return Withdrawal(reason=reason, date=date, kind=kind)
|
|
146
155
|
|
|
147
156
|
|
|
148
157
|
# Old source-kind names (pre-0.3.58) mapped to the primary/secondary/assumption ladder, so
|
|
@@ -161,17 +161,33 @@ class Attestation:
|
|
|
161
161
|
comment: str | None = None # the review's feedback / rationale, kept on the record
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
class WithdrawalKind(StrEnum):
|
|
165
|
+
"""Why a need is off the board — the decision's *kind*, as data, not only free-text reason.
|
|
166
|
+
|
|
167
|
+
All three take the need off the active board and out of coverage identically; the kind records
|
|
168
|
+
*why* so the board can tell a validated-but-off-strategy need apart from one killed as not
|
|
169
|
+
worth doing. ``OUT_OF_SCOPE`` is the case a real, client-evidenced need is declined because it
|
|
170
|
+
is not the product being built — distinct from ``WONT_DO`` (decided against on its merits) and
|
|
171
|
+
``DUPLICATE`` (the same want is tracked under another need).
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
WONT_DO = "wont_do" # decided against on its merits — the default, #74's original meaning
|
|
175
|
+
OUT_OF_SCOPE = "out_of_scope" # real and valid, but not this product
|
|
176
|
+
DUPLICATE = "duplicate" # the same want is tracked elsewhere
|
|
177
|
+
|
|
178
|
+
|
|
164
179
|
@dataclass(frozen=True)
|
|
165
180
|
class Withdrawal:
|
|
166
181
|
"""A decision not to pursue a need — withdrawn / won't-do (#74).
|
|
167
182
|
|
|
168
183
|
Unlike a draft (staged, not yet decided), a withdrawal is a decision *against*: the need is
|
|
169
|
-
taken off the active board and out of coverage, with its ``reason`` and ``
|
|
170
|
-
record as the durable trace (``prodc show`` still renders it). Reinstate to put it back.
|
|
184
|
+
taken off the active board and out of coverage, with its ``reason``, ``date`` and ``kind`` kept
|
|
185
|
+
on the record as the durable trace (``prodc show`` still renders it). Reinstate to put it back.
|
|
171
186
|
"""
|
|
172
187
|
|
|
173
188
|
reason: str
|
|
174
189
|
date: str
|
|
190
|
+
kind: WithdrawalKind = WithdrawalKind.WONT_DO
|
|
175
191
|
|
|
176
192
|
|
|
177
193
|
@dataclass(frozen=True)
|
|
@@ -445,6 +445,62 @@ def proof_suggestions(
|
|
|
445
445
|
return out
|
|
446
446
|
|
|
447
447
|
|
|
448
|
+
@dataclass(frozen=True)
|
|
449
|
+
class RelinkSuggestion:
|
|
450
|
+
"""A candidate replacement for a BROKEN proof locator — a renamed test, relinkable (#54)."""
|
|
451
|
+
|
|
452
|
+
need: Need
|
|
453
|
+
adapter: str # the adapter the broken locator (and the replacement) resolve through
|
|
454
|
+
broken: str # the locator currently on the need that no longer resolves
|
|
455
|
+
locator: str # the proposed replacement, "<adapter>:<inventory id>"
|
|
456
|
+
score: float
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def relink_suggestions(
|
|
460
|
+
results: list[NeedResult],
|
|
461
|
+
adapters: dict[str, Adapter],
|
|
462
|
+
*,
|
|
463
|
+
min_score: float,
|
|
464
|
+
top: int,
|
|
465
|
+
kind: str | None = None,
|
|
466
|
+
) -> list[RelinkSuggestion]:
|
|
467
|
+
"""Propose the nearest live inventory entry for each BROKEN proof locator (#54 relink).
|
|
468
|
+
|
|
469
|
+
A relink is for a locator whose adapter still exists but whose id no longer resolves — a test
|
|
470
|
+
that was renamed. So it only proposes within the *same* adapter (the kind is stable, the id
|
|
471
|
+
drifted), scoring each inventory entry by the similarity of its matchable part to the broken
|
|
472
|
+
id's matchable part, falling back to the need text. A locator whose adapter is absent entirely
|
|
473
|
+
cannot be relinked (there is no inventory to match against) and is skipped. Read-only.
|
|
474
|
+
"""
|
|
475
|
+
out: list[RelinkSuggestion] = []
|
|
476
|
+
for r in results:
|
|
477
|
+
if r.state is not NeedState.BROKEN or r.need.children:
|
|
478
|
+
continue
|
|
479
|
+
for proof in r.need.proofs:
|
|
480
|
+
if _resolve_proof(proof, adapters).state is not ProofState.BROKEN:
|
|
481
|
+
continue
|
|
482
|
+
name, _, payload = proof.partition(":")
|
|
483
|
+
if kind is not None and name != kind:
|
|
484
|
+
continue
|
|
485
|
+
ad = adapters.get(name)
|
|
486
|
+
if not isinstance(ad, Inventoried):
|
|
487
|
+
continue # adapter gone, or can't enumerate — nothing to relink against
|
|
488
|
+
scored: list[RelinkSuggestion] = []
|
|
489
|
+
for inv in ad.inventory():
|
|
490
|
+
candidate = f"{name}:{inv}"
|
|
491
|
+
if candidate in r.need.proofs:
|
|
492
|
+
continue # already on the need
|
|
493
|
+
score = max(
|
|
494
|
+
similarity(_matchable(payload), _matchable(inv)),
|
|
495
|
+
similarity(r.need.text, _matchable(inv)),
|
|
496
|
+
)
|
|
497
|
+
if score >= min_score:
|
|
498
|
+
scored.append(RelinkSuggestion(r.need, name, proof, candidate, score))
|
|
499
|
+
scored.sort(key=lambda s: (-s.score, s.locator))
|
|
500
|
+
out.extend(scored[:top])
|
|
501
|
+
return out
|
|
502
|
+
|
|
503
|
+
|
|
448
504
|
_STATE_RANK = {
|
|
449
505
|
NeedState.BROKEN: 1,
|
|
450
506
|
NeedState.PENDING: 2,
|
|
@@ -474,16 +474,21 @@ def withdraw(
|
|
|
474
474
|
*,
|
|
475
475
|
reason: str,
|
|
476
476
|
date: str,
|
|
477
|
+
kind: str = "wont_do",
|
|
477
478
|
expect_sha: str | None,
|
|
478
479
|
dry_run: bool,
|
|
479
480
|
) -> Edit:
|
|
480
481
|
"""Record a decision against a need — withdrawn / won't-do (#74).
|
|
481
482
|
|
|
482
|
-
Writes a ``withdrawn: {reason, date}`` mapping onto the need, which takes it off the
|
|
483
|
-
board and out of coverage; the record stays as its durable trace.
|
|
484
|
-
|
|
483
|
+
Writes a ``withdrawn: {reason, date, kind?}`` mapping onto the need, which takes it off the
|
|
484
|
+
active board and out of coverage; the record stays as its durable trace. ``kind`` says *why*
|
|
485
|
+
(``wont_do`` default, ``out_of_scope``, ``duplicate``) and is written only when it is not the
|
|
486
|
+
default, to keep the common case clean. Replaces any existing withdrawal (re-withdrawing
|
|
487
|
+
updates it). Reinstate with :func:`reinstate`.
|
|
485
488
|
"""
|
|
486
489
|
mark: dict[str, object] = {"reason": reason, "date": date}
|
|
490
|
+
if kind != "wont_do":
|
|
491
|
+
mark["kind"] = kind
|
|
487
492
|
path, _ = _locate(config, need_id)
|
|
488
493
|
doc, old_text = _read_doc(path)
|
|
489
494
|
if expect_sha is not None and _sha256(old_text) != expect_sha:
|
|
@@ -603,6 +608,46 @@ def add_proof(
|
|
|
603
608
|
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
604
609
|
|
|
605
610
|
|
|
611
|
+
def relink_proof(
|
|
612
|
+
config: Config,
|
|
613
|
+
need_id: str,
|
|
614
|
+
old_locator: str,
|
|
615
|
+
new_locator: str,
|
|
616
|
+
*,
|
|
617
|
+
expect_sha: str | None = None,
|
|
618
|
+
dry_run: bool,
|
|
619
|
+
) -> Edit:
|
|
620
|
+
"""Replace one (broken) proof locator with a proposed one, in place — the relink of #54.
|
|
621
|
+
|
|
622
|
+
The write path for ``suggest --relink --apply``: a renamed test keeps its adapter kind but
|
|
623
|
+
gets a new id, so this swaps ``old_locator`` for ``new_locator`` on the need and de-duplicates
|
|
624
|
+
(if the replacement already sits on the need, the broken one is simply dropped). Errors if the
|
|
625
|
+
old locator is not actually on the need. One reload-gated single-file splice, like
|
|
626
|
+
:func:`add_proof`.
|
|
627
|
+
"""
|
|
628
|
+
path, _ = _locate(config, need_id)
|
|
629
|
+
doc, old_text = _read_doc(path)
|
|
630
|
+
if expect_sha is not None and _sha256(old_text) != expect_sha:
|
|
631
|
+
raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
|
|
632
|
+
for raw in _need_list(doc, path):
|
|
633
|
+
if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
|
|
634
|
+
need = cast("dict[str, object]", raw)
|
|
635
|
+
current = need.get("proofs", [])
|
|
636
|
+
if not isinstance(current, list):
|
|
637
|
+
raise WriteError(f"{path.name}: 'proofs' is not a list")
|
|
638
|
+
proofs = list(cast("list[object]", current))
|
|
639
|
+
if old_locator not in proofs:
|
|
640
|
+
raise WriteError(f"{old_locator!r} is not a proof on {need_id!r}")
|
|
641
|
+
swapped = [new_locator if p == old_locator else p for p in proofs]
|
|
642
|
+
deduped: list[object] = []
|
|
643
|
+
for p in swapped:
|
|
644
|
+
if p not in deduped:
|
|
645
|
+
deduped.append(p)
|
|
646
|
+
need["proofs"] = deduped
|
|
647
|
+
break
|
|
648
|
+
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
649
|
+
|
|
650
|
+
|
|
606
651
|
def add_need(
|
|
607
652
|
config: Config,
|
|
608
653
|
need_id: str,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""BROKEN relink (#54): propose a live inventory entry to replace a renamed-test locator."""
|
|
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
|
+
[proofs.web]
|
|
18
|
+
kind = "gherkin"
|
|
19
|
+
paths = ["features/*.feature"]
|
|
20
|
+
delivered = ["@implemented"]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# The scenario the need points at was renamed: "...order" -> "...an order".
|
|
24
|
+
_FEATURE = """
|
|
25
|
+
Feature: orders
|
|
26
|
+
@implemented
|
|
27
|
+
Scenario: Owner cancels an order
|
|
28
|
+
Given a thing
|
|
29
|
+
Then it works
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_NEEDS = """
|
|
33
|
+
needs:
|
|
34
|
+
- id: N-1
|
|
35
|
+
text: the owner can cancel an order
|
|
36
|
+
proofs: ["web:orders.feature#Owner cancels order"]
|
|
37
|
+
- id: H-1
|
|
38
|
+
text: an unrelated hole
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _project(tmp_path: Path) -> Path:
|
|
43
|
+
(tmp_path / "needs").mkdir(parents=True, exist_ok=True)
|
|
44
|
+
(tmp_path / "features").mkdir(parents=True, exist_ok=True)
|
|
45
|
+
(tmp_path / "prodc.toml").write_text(_TOML, encoding="utf-8")
|
|
46
|
+
(tmp_path / "needs" / "a.yaml").write_text(_NEEDS, encoding="utf-8")
|
|
47
|
+
(tmp_path / "features" / "orders.feature").write_text(_FEATURE, encoding="utf-8")
|
|
48
|
+
return tmp_path / "prodc.toml"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_relink_proposes_renamed_scenario(
|
|
52
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
53
|
+
) -> None:
|
|
54
|
+
main(["suggest", "--relink", "--config", str(_project(tmp_path)), "--json"])
|
|
55
|
+
payload = json.loads(capsys.readouterr().out)
|
|
56
|
+
assert len(payload) == 1
|
|
57
|
+
s = payload[0]
|
|
58
|
+
assert s["need"] == "N-1"
|
|
59
|
+
assert s["broken"] == "web:orders.feature#Owner cancels order"
|
|
60
|
+
assert s["locator"] == "web:orders.feature#Owner cancels an order"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_relink_only_targets_broken_not_hole(
|
|
64
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
65
|
+
) -> None:
|
|
66
|
+
main(["suggest", "--relink", "--config", str(_project(tmp_path)), "--json"])
|
|
67
|
+
payload = json.loads(capsys.readouterr().out)
|
|
68
|
+
assert {s["need"] for s in payload} == {"N-1"} # H-1 is a HOLE, not a relink target
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_relink_apply_swaps_locator_and_delivers(
|
|
72
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
73
|
+
) -> None:
|
|
74
|
+
cfg = _project(tmp_path)
|
|
75
|
+
code = main(["suggest", "--relink", "--apply", "--config", str(cfg)])
|
|
76
|
+
assert code == 0
|
|
77
|
+
capsys.readouterr()
|
|
78
|
+
need = {n.id: n for n in load(Config.load(cfg)).needs}["N-1"]
|
|
79
|
+
assert need.proofs == ("web:orders.feature#Owner cancels an order",)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_relink_apply_dry_run_writes_nothing(
|
|
83
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
84
|
+
) -> None:
|
|
85
|
+
cfg = _project(tmp_path)
|
|
86
|
+
before = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
|
|
87
|
+
code = main(["suggest", "--relink", "--apply", "--dry-run", "--config", str(cfg)])
|
|
88
|
+
assert code == 0
|
|
89
|
+
capsys.readouterr()
|
|
90
|
+
assert (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8") == before
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_relink_skips_when_adapter_absent(
|
|
94
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
95
|
+
) -> None:
|
|
96
|
+
# A locator whose adapter does not exist is BROKEN but has no inventory to relink against.
|
|
97
|
+
(tmp_path / "needs").mkdir(parents=True, exist_ok=True)
|
|
98
|
+
(tmp_path / "prodc.toml").write_text("[prodc]\nneeds = [\"needs/*.yaml\"]\n", encoding="utf-8")
|
|
99
|
+
(tmp_path / "needs" / "a.yaml").write_text(
|
|
100
|
+
"needs:\n - id: N-1\n text: t\n proofs: ['ghost:1']\n", encoding="utf-8"
|
|
101
|
+
)
|
|
102
|
+
main(["suggest", "--relink", "--config", str(tmp_path / "prodc.toml"), "--json"])
|
|
103
|
+
assert json.loads(capsys.readouterr().out) == []
|
|
@@ -242,7 +242,11 @@ def test_show_json_carries_withdrawn(tmp_path: Path, capsys: pytest.CaptureFixtu
|
|
|
242
242
|
|
|
243
243
|
main(["show", "W-1", "--config", str(_project(tmp_path)), "--json"])
|
|
244
244
|
payload = json.loads(capsys.readouterr().out)
|
|
245
|
-
assert payload["withdrawn"] == {
|
|
245
|
+
assert payload["withdrawn"] == {
|
|
246
|
+
"reason": "superseded by N-1",
|
|
247
|
+
"date": "2026-10-10",
|
|
248
|
+
"kind": "wont_do", # default when the YAML omits kind
|
|
249
|
+
}
|
|
246
250
|
|
|
247
251
|
|
|
248
252
|
def test_status_json_live_need_has_null_withdrawn(
|
|
@@ -254,3 +258,85 @@ def test_status_json_live_need_has_null_withdrawn(
|
|
|
254
258
|
payload = json.loads(capsys.readouterr().out)
|
|
255
259
|
n1 = next(n for n in payload["needs"] if n["id"] == "N-1")
|
|
256
260
|
assert n1["withdrawn"] is None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --- withdrawal kind (#74 follow-up) ----------------------------------------
|
|
264
|
+
|
|
265
|
+
_KIND_NEEDS = """
|
|
266
|
+
needs:
|
|
267
|
+
- id: N-1
|
|
268
|
+
text: user can see a delivered thing
|
|
269
|
+
proofs: ["x:1"]
|
|
270
|
+
- id: OOS-1
|
|
271
|
+
text: a real but off-strategy need
|
|
272
|
+
withdrawn:
|
|
273
|
+
reason: not the product we build
|
|
274
|
+
date: 2026-10-10
|
|
275
|
+
kind: out_of_scope
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_loader_parses_withdrawal_kind(tmp_path: Path) -> None:
|
|
280
|
+
from prodc.model import WithdrawalKind
|
|
281
|
+
|
|
282
|
+
cfg = Config.load(_project(tmp_path, needs=_KIND_NEEDS))
|
|
283
|
+
by_id = {n.id: n for n in load(cfg).needs}
|
|
284
|
+
assert by_id["OOS-1"].withdrawn is not None
|
|
285
|
+
assert by_id["OOS-1"].withdrawn.kind is WithdrawalKind.OUT_OF_SCOPE
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_withdrawal_kind_defaults_wont_do(tmp_path: Path) -> None:
|
|
289
|
+
from prodc.model import WithdrawalKind
|
|
290
|
+
|
|
291
|
+
cfg = Config.load(_project(tmp_path)) # W-1 omits kind
|
|
292
|
+
by_id = {n.id: n for n in load(cfg).needs}
|
|
293
|
+
w = by_id["W-1"].withdrawn
|
|
294
|
+
assert w is not None and w.kind is WithdrawalKind.WONT_DO
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_unknown_withdrawal_kind_rejected(tmp_path: Path) -> None:
|
|
298
|
+
needs = _KIND_NEEDS.replace("kind: out_of_scope", "kind: bogus")
|
|
299
|
+
with pytest.raises(LoaderError, match="unknown withdrawal kind 'bogus'"):
|
|
300
|
+
load(Config.load(_project(tmp_path, needs=needs)))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_withdraw_writes_non_default_kind(tmp_path: Path) -> None:
|
|
304
|
+
from prodc.cli import main
|
|
305
|
+
from prodc.model import WithdrawalKind
|
|
306
|
+
|
|
307
|
+
cfg = _project(tmp_path)
|
|
308
|
+
code = main(
|
|
309
|
+
["withdraw", "N-1", "--reason", "off strategy", "--kind", "out_of_scope",
|
|
310
|
+
"--date", "2026-10-15", "--config", str(cfg)]
|
|
311
|
+
)
|
|
312
|
+
assert code == 0
|
|
313
|
+
by_id = {n.id: n for n in load(Config.load(cfg)).needs}
|
|
314
|
+
w = by_id["N-1"].withdrawn
|
|
315
|
+
assert w is not None and w.kind is WithdrawalKind.OUT_OF_SCOPE
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_withdraw_omits_default_kind_in_yaml(tmp_path: Path) -> None:
|
|
319
|
+
from prodc.cli import main
|
|
320
|
+
|
|
321
|
+
cfg = _project(tmp_path)
|
|
322
|
+
main(["withdraw", "N-1", "--reason", "nope", "--date", "2026-10-15", "--config", str(cfg)])
|
|
323
|
+
text = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
|
|
324
|
+
assert "kind:" not in text # wont_do is the default; not written, keeps files clean
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_status_breaks_down_withdrawn_by_kind(
|
|
328
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
329
|
+
) -> None:
|
|
330
|
+
from prodc.cli import main
|
|
331
|
+
|
|
332
|
+
main(["status", "--config", str(_project(tmp_path, needs=_KIND_NEEDS))])
|
|
333
|
+
out = capsys.readouterr().out
|
|
334
|
+
assert "withdrawn: 1 (decided against) — 1 out_of_scope" in out
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_show_tags_non_default_kind(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
338
|
+
from prodc.cli import main
|
|
339
|
+
|
|
340
|
+
main(["show", "OOS-1", "--config", str(_project(tmp_path, needs=_KIND_NEEDS))])
|
|
341
|
+
out = capsys.readouterr().out
|
|
342
|
+
assert "withdrawn [out_of_scope]: not the product we build (2026-10-10)" in out
|
|
@@ -15,6 +15,7 @@ from prodc.writer import (
|
|
|
15
15
|
add_role,
|
|
16
16
|
capture_source,
|
|
17
17
|
delete_need,
|
|
18
|
+
relink_proof,
|
|
18
19
|
set_field,
|
|
19
20
|
)
|
|
20
21
|
|
|
@@ -303,6 +304,27 @@ def test_delete_need_dry_run_leaves_file(tmp_path: Path) -> None:
|
|
|
303
304
|
assert "N-1" in edit.diff and edit.diff.startswith("---")
|
|
304
305
|
|
|
305
306
|
|
|
307
|
+
def test_relink_proof_swaps_locator(tmp_path: Path) -> None:
|
|
308
|
+
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n proofs: ['web:a#Old']\n")
|
|
309
|
+
relink_proof(cfg, "N-1", "web:a#Old", "web:a#New", dry_run=False)
|
|
310
|
+
assert load(cfg).needs[0].proofs == ("web:a#New",)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_relink_proof_rejects_absent_old(tmp_path: Path) -> None:
|
|
314
|
+
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n proofs: ['web:a#Old']\n")
|
|
315
|
+
with pytest.raises(WriteError, match="is not a proof on 'N-1'"):
|
|
316
|
+
relink_proof(cfg, "N-1", "web:a#Missing", "web:a#New", dry_run=False)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_relink_proof_dedupes_when_new_already_present(tmp_path: Path) -> None:
|
|
320
|
+
cfg = _proj(
|
|
321
|
+
tmp_path,
|
|
322
|
+
"needs:\n - id: N-1\n text: t\n proofs: ['web:a#Old', 'web:a#New']\n",
|
|
323
|
+
)
|
|
324
|
+
relink_proof(cfg, "N-1", "web:a#Old", "web:a#New", dry_run=False)
|
|
325
|
+
assert load(cfg).needs[0].proofs == ("web:a#New",) # broken dropped, no duplicate
|
|
326
|
+
|
|
327
|
+
|
|
306
328
|
def test_add_draft_stamps_draft_added(tmp_path: Path) -> None:
|
|
307
329
|
cfg = _proj(tmp_path, "needs: []\n")
|
|
308
330
|
add_need(
|
|
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
|