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.
Files changed (61) hide show
  1. {prodc-0.3.75 → prodc-0.3.77}/PKG-INFO +1 -1
  2. {prodc-0.3.75 → prodc-0.3.77}/pyproject.toml +1 -1
  3. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/agent.md +2 -1
  4. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/board.py +10 -2
  5. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/app.py +13 -1
  6. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/commands.py +100 -2
  7. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/render.py +3 -1
  8. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/loader.py +13 -4
  9. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/model.py +18 -2
  10. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/status.py +56 -0
  11. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/writer.py +48 -3
  12. prodc-0.3.77/tests/test_relink.py +103 -0
  13. {prodc-0.3.75 → prodc-0.3.77}/tests/test_withdrawn.py +87 -1
  14. {prodc-0.3.75 → prodc-0.3.77}/tests/test_writer.py +22 -0
  15. {prodc-0.3.75 → prodc-0.3.77}/.gitignore +0 -0
  16. {prodc-0.3.75 → prodc-0.3.77}/CHANGELOG.md +0 -0
  17. {prodc-0.3.75 → prodc-0.3.77}/LICENSE +0 -0
  18. {prodc-0.3.75 → prodc-0.3.77}/README.md +0 -0
  19. {prodc-0.3.75 → prodc-0.3.77}/examples/quickstart/features/orders.feature +0 -0
  20. {prodc-0.3.75 → prodc-0.3.77}/examples/quickstart/needs/jordan.yaml +0 -0
  21. {prodc-0.3.75 → prodc-0.3.77}/examples/quickstart/prodc.toml +0 -0
  22. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/__init__.py +0 -0
  23. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/adapters.py +0 -0
  24. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/callbacks.py +0 -0
  25. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/__init__.py +0 -0
  26. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/common.py +0 -0
  27. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/cli/serialize.py +0 -0
  28. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/config.py +0 -0
  29. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/dedup.py +0 -0
  30. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/py.typed +0 -0
  31. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/report.py +0 -0
  32. {prodc-0.3.75 → prodc-0.3.77}/src/prodc/report_html.py +0 -0
  33. {prodc-0.3.75 → prodc-0.3.77}/tests/test_adapters.py +0 -0
  34. {prodc-0.3.75 → prodc-0.3.77}/tests/test_answer.py +0 -0
  35. {prodc-0.3.75 → prodc-0.3.77}/tests/test_callbacks.py +0 -0
  36. {prodc-0.3.75 → prodc-0.3.77}/tests/test_citation.py +0 -0
  37. {prodc-0.3.75 → prodc-0.3.77}/tests/test_claim_gating.py +0 -0
  38. {prodc-0.3.75 → prodc-0.3.77}/tests/test_cli.py +0 -0
  39. {prodc-0.3.75 → prodc-0.3.77}/tests/test_command_hook.py +0 -0
  40. {prodc-0.3.75 → prodc-0.3.77}/tests/test_dedup.py +0 -0
  41. {prodc-0.3.75 → prodc-0.3.77}/tests/test_description.py +0 -0
  42. {prodc-0.3.75 → prodc-0.3.77}/tests/test_drafts.py +0 -0
  43. {prodc-0.3.75 → prodc-0.3.77}/tests/test_evalgate.py +0 -0
  44. {prodc-0.3.75 → prodc-0.3.77}/tests/test_external_adapter.py +0 -0
  45. {prodc-0.3.75 → prodc-0.3.77}/tests/test_gitlab_source.py +0 -0
  46. {prodc-0.3.75 → prodc-0.3.77}/tests/test_importance.py +0 -0
  47. {prodc-0.3.75 → prodc-0.3.77}/tests/test_loader.py +0 -0
  48. {prodc-0.3.75 → prodc-0.3.77}/tests/test_models.py +0 -0
  49. {prodc-0.3.75 → prodc-0.3.77}/tests/test_needref.py +0 -0
  50. {prodc-0.3.75 → prodc-0.3.77}/tests/test_report.py +0 -0
  51. {prodc-0.3.75 → prodc-0.3.77}/tests/test_report_html.py +0 -0
  52. {prodc-0.3.75 → prodc-0.3.77}/tests/test_review.py +0 -0
  53. {prodc-0.3.75 → prodc-0.3.77}/tests/test_roles.py +0 -0
  54. {prodc-0.3.75 → prodc-0.3.77}/tests/test_source_role.py +0 -0
  55. {prodc-0.3.75 → prodc-0.3.77}/tests/test_sources.py +0 -0
  56. {prodc-0.3.75 → prodc-0.3.77}/tests/test_staleness.py +0 -0
  57. {prodc-0.3.75 → prodc-0.3.77}/tests/test_status.py +0 -0
  58. {prodc-0.3.75 → prodc-0.3.77}/tests/test_suggest.py +0 -0
  59. {prodc-0.3.75 → prodc-0.3.77}/tests/test_variants.py +0 -0
  60. {prodc-0.3.75 → prodc-0.3.77}/tests/test_who_variants.py +0 -0
  61. {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.75
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.75"
3
+ version = "0.3.77"
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
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
- {"reason": need.withdrawn.reason, "date": need.withdrawn.date}
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); honours --min-score",
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
- print(f"withdrawn: {withdrawn_count} (decided against) (#74)")
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 adapters' inventories (#54)."""
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
- lines.append(f" withdrawn: {need.withdrawn.reason} ({need.withdrawn.date})")
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
- Both fields are required when the key is present — a decision against a need must say why
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
- return Withdrawal(reason=reason, date=date)
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 ``date`` kept on the
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 active
483
- board and out of coverage; the record stays as its durable trace. Replaces any existing
484
- withdrawal (re-withdrawing updates the reason/date). Reinstate with :func:`reinstate`.
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"] == {"reason": "superseded by N-1", "date": "2026-10-10"}
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