prodc 0.3.75__tar.gz → 0.3.76__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.76}/PKG-INFO +1 -1
- {prodc-0.3.75 → prodc-0.3.76}/pyproject.toml +1 -1
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/agent.md +1 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/cli/app.py +6 -1
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/cli/commands.py +88 -1
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/status.py +56 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/writer.py +40 -0
- prodc-0.3.76/tests/test_relink.py +103 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_writer.py +22 -0
- {prodc-0.3.75 → prodc-0.3.76}/.gitignore +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/CHANGELOG.md +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/LICENSE +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/README.md +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/__init__.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/adapters.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/board.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/cli/render.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/config.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/dedup.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/loader.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/model.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/py.typed +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/report.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/src/prodc/report_html.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_adapters.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_answer.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_callbacks.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_citation.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_cli.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_command_hook.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_dedup.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_description.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_drafts.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_evalgate.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_importance.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_loader.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_models.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_needref.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_report.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_report_html.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_review.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_roles.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_source_role.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_sources.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_staleness.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_status.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_suggest.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_variants.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_who_variants.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/tests/test_withdrawn.py +0 -0
- {prodc-0.3.75 → prodc-0.3.76}/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.76
|
|
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,6 +75,7 @@ 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>` |
|
|
@@ -433,10 +433,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
433
433
|
sg.add_argument(
|
|
434
434
|
"--kind", default=None, metavar="ADAPTER", help="only suggest from this adapter name"
|
|
435
435
|
)
|
|
436
|
+
sg.add_argument(
|
|
437
|
+
"--relink",
|
|
438
|
+
action="store_true",
|
|
439
|
+
help="BROKEN-need mode: propose a live inventory entry to replace each broken locator (#54)",
|
|
440
|
+
)
|
|
436
441
|
sg.add_argument(
|
|
437
442
|
"--apply",
|
|
438
443
|
action="store_true",
|
|
439
|
-
help="write the best suggestion per need as a proof (#54)
|
|
444
|
+
help="write the best suggestion per need as a proof (relink: replace the broken one) (#54)",
|
|
440
445
|
)
|
|
441
446
|
sg.add_argument(
|
|
442
447
|
"--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,
|
|
@@ -954,7 +957,7 @@ def cmd_agent(args: argparse.Namespace) -> int:
|
|
|
954
957
|
|
|
955
958
|
|
|
956
959
|
def cmd_suggest(args: argparse.Namespace) -> int:
|
|
957
|
-
"""Propose candidate proof locators for HOLE needs from the
|
|
960
|
+
"""Propose candidate proof locators for HOLE needs (or relink BROKEN ones) from the inventories (#54)."""
|
|
958
961
|
loaded = load_or_report(args)
|
|
959
962
|
if loaded is None:
|
|
960
963
|
return 2
|
|
@@ -966,6 +969,8 @@ def cmd_suggest(args: argparse.Namespace) -> int:
|
|
|
966
969
|
if not results:
|
|
967
970
|
print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
|
|
968
971
|
return 2
|
|
972
|
+
if args.relink:
|
|
973
|
+
return _cmd_relink(cfg, results, args)
|
|
969
974
|
suggestions = proof_suggestions(
|
|
970
975
|
results, cfg.adapters, min_score=args.min_score, top=args.top, kind=args.kind
|
|
971
976
|
)
|
|
@@ -1044,6 +1049,88 @@ def _apply_suggestions(
|
|
|
1044
1049
|
return 0
|
|
1045
1050
|
|
|
1046
1051
|
|
|
1052
|
+
def _cmd_relink(cfg: Config, results: list[NeedResult], args: argparse.Namespace) -> int:
|
|
1053
|
+
"""Propose (or --apply) a live inventory entry to replace each BROKEN proof locator (#54)."""
|
|
1054
|
+
suggestions = relink_suggestions(
|
|
1055
|
+
results, cfg.adapters, min_score=args.min_score, top=args.top, kind=args.kind
|
|
1056
|
+
)
|
|
1057
|
+
if args.apply:
|
|
1058
|
+
return _apply_relinks(cfg, suggestions, args)
|
|
1059
|
+
if args.json:
|
|
1060
|
+
print(
|
|
1061
|
+
json.dumps(
|
|
1062
|
+
[
|
|
1063
|
+
{
|
|
1064
|
+
"need": s.need.id,
|
|
1065
|
+
"adapter": s.adapter,
|
|
1066
|
+
"broken": s.broken,
|
|
1067
|
+
"locator": s.locator,
|
|
1068
|
+
"score": round(s.score, 4),
|
|
1069
|
+
}
|
|
1070
|
+
for s in suggestions
|
|
1071
|
+
],
|
|
1072
|
+
indent=2,
|
|
1073
|
+
)
|
|
1074
|
+
)
|
|
1075
|
+
else:
|
|
1076
|
+
last = None
|
|
1077
|
+
for s in suggestions:
|
|
1078
|
+
if s.need.id != last:
|
|
1079
|
+
print(f"{s.need.id}: {s.need.text}")
|
|
1080
|
+
last = s.need.id
|
|
1081
|
+
print(f" {s.score:.2f} {s.broken} → {s.locator}")
|
|
1082
|
+
print(f"suggest --relink: {len(suggestions)} candidate(s) at min-score {args.min_score:.2f}")
|
|
1083
|
+
return 0
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _apply_relinks(
|
|
1087
|
+
cfg: Config, suggestions: list[RelinkSuggestion], args: argparse.Namespace
|
|
1088
|
+
) -> int:
|
|
1089
|
+
"""Apply the best relink per broken locator, printing each need's diff (#54 relink).
|
|
1090
|
+
|
|
1091
|
+
Keyed by ``(need, broken locator)`` so a need with two broken proofs relinks both to their own
|
|
1092
|
+
best candidate. Each is one reload-gated single-file edit (:func:`relink_proof`), applied in a
|
|
1093
|
+
stable order; a rejected write aborts with a non-zero exit (earlier writes stand).
|
|
1094
|
+
"""
|
|
1095
|
+
best: dict[tuple[str, str], RelinkSuggestion] = {}
|
|
1096
|
+
for s in suggestions:
|
|
1097
|
+
best.setdefault((s.need.id, s.broken), s)
|
|
1098
|
+
applied: list[tuple[RelinkSuggestion, Edit]] = []
|
|
1099
|
+
for key in sorted(best):
|
|
1100
|
+
s = best[key]
|
|
1101
|
+
try:
|
|
1102
|
+
edit = relink_proof(cfg, s.need.id, s.broken, s.locator, dry_run=args.dry_run)
|
|
1103
|
+
except WriteError as exc:
|
|
1104
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
1105
|
+
return 2
|
|
1106
|
+
applied.append((s, edit))
|
|
1107
|
+
if args.json:
|
|
1108
|
+
print(
|
|
1109
|
+
json.dumps(
|
|
1110
|
+
[
|
|
1111
|
+
{
|
|
1112
|
+
"need": s.need.id,
|
|
1113
|
+
"broken": s.broken,
|
|
1114
|
+
"locator": s.locator,
|
|
1115
|
+
"score": round(s.score, 4),
|
|
1116
|
+
"file": edit.path.name,
|
|
1117
|
+
"dry_run": args.dry_run,
|
|
1118
|
+
"diff": edit.diff,
|
|
1119
|
+
}
|
|
1120
|
+
for s, edit in applied
|
|
1121
|
+
],
|
|
1122
|
+
indent=2,
|
|
1123
|
+
)
|
|
1124
|
+
)
|
|
1125
|
+
else:
|
|
1126
|
+
verb = "would relink" if args.dry_run else "relinked"
|
|
1127
|
+
for s, edit in applied:
|
|
1128
|
+
print(f"{verb} {s.broken} → {s.locator} on {s.need.id} ({s.score:.2f}) in {edit.path.name}")
|
|
1129
|
+
print(edit.diff, end="")
|
|
1130
|
+
print(f"suggest --relink --apply: {verb} {len(applied)} locator(s)")
|
|
1131
|
+
return 0
|
|
1132
|
+
|
|
1133
|
+
|
|
1047
1134
|
def cmd_recurrence(args: argparse.Namespace) -> int:
|
|
1048
1135
|
"""Show baselines shared across clients; `--suggest` proposes unlinked variant groups (#56)."""
|
|
1049
1136
|
loaded = load_or_report(args)
|
|
@@ -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,
|
|
@@ -603,6 +603,46 @@ def add_proof(
|
|
|
603
603
|
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
604
604
|
|
|
605
605
|
|
|
606
|
+
def relink_proof(
|
|
607
|
+
config: Config,
|
|
608
|
+
need_id: str,
|
|
609
|
+
old_locator: str,
|
|
610
|
+
new_locator: str,
|
|
611
|
+
*,
|
|
612
|
+
expect_sha: str | None = None,
|
|
613
|
+
dry_run: bool,
|
|
614
|
+
) -> Edit:
|
|
615
|
+
"""Replace one (broken) proof locator with a proposed one, in place — the relink of #54.
|
|
616
|
+
|
|
617
|
+
The write path for ``suggest --relink --apply``: a renamed test keeps its adapter kind but
|
|
618
|
+
gets a new id, so this swaps ``old_locator`` for ``new_locator`` on the need and de-duplicates
|
|
619
|
+
(if the replacement already sits on the need, the broken one is simply dropped). Errors if the
|
|
620
|
+
old locator is not actually on the need. One reload-gated single-file splice, like
|
|
621
|
+
:func:`add_proof`.
|
|
622
|
+
"""
|
|
623
|
+
path, _ = _locate(config, need_id)
|
|
624
|
+
doc, old_text = _read_doc(path)
|
|
625
|
+
if expect_sha is not None and _sha256(old_text) != expect_sha:
|
|
626
|
+
raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
|
|
627
|
+
for raw in _need_list(doc, path):
|
|
628
|
+
if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
|
|
629
|
+
need = cast("dict[str, object]", raw)
|
|
630
|
+
current = need.get("proofs", [])
|
|
631
|
+
if not isinstance(current, list):
|
|
632
|
+
raise WriteError(f"{path.name}: 'proofs' is not a list")
|
|
633
|
+
proofs = list(cast("list[object]", current))
|
|
634
|
+
if old_locator not in proofs:
|
|
635
|
+
raise WriteError(f"{old_locator!r} is not a proof on {need_id!r}")
|
|
636
|
+
swapped = [new_locator if p == old_locator else p for p in proofs]
|
|
637
|
+
deduped: list[object] = []
|
|
638
|
+
for p in swapped:
|
|
639
|
+
if p not in deduped:
|
|
640
|
+
deduped.append(p)
|
|
641
|
+
need["proofs"] = deduped
|
|
642
|
+
break
|
|
643
|
+
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
644
|
+
|
|
645
|
+
|
|
606
646
|
def add_need(
|
|
607
647
|
config: Config,
|
|
608
648
|
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) == []
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|