prodc 0.3.77__tar.gz → 0.3.78__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.77 → prodc-0.3.78}/PKG-INFO +1 -1
- {prodc-0.3.77 → prodc-0.3.78}/pyproject.toml +1 -1
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/app.py +7 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/commands.py +27 -5
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/render.py +18 -3
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/serialize.py +14 -3
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/config.py +3 -1
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/loader.py +3 -1
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/model.py +6 -2
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/status.py +18 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_importance.py +49 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_relink.py +1 -1
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_review.py +6 -2
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_source_role.py +1 -1
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_staleness.py +48 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_withdrawn.py +12 -2
- {prodc-0.3.77 → prodc-0.3.78}/.gitignore +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/CHANGELOG.md +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/LICENSE +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/README.md +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/__init__.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/adapters.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/agent.md +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/board.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/cli/common.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/dedup.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/py.typed +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/report.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/report_html.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/src/prodc/writer.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_adapters.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_answer.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_callbacks.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_citation.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_cli.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_command_hook.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_dedup.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_description.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_drafts.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_evalgate.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_loader.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_models.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_needref.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_report.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_report_html.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_roles.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_sources.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_status.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_suggest.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_variants.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_who_variants.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/tests/test_writer.py +0 -0
- {prodc-0.3.77 → prodc-0.3.78}/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.78
|
|
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
|
|
@@ -133,6 +133,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
133
133
|
pl.add_argument(
|
|
134
134
|
"--include-drafts", action="store_true", help="include draft (staged) needs (#60)"
|
|
135
135
|
)
|
|
136
|
+
pl.add_argument(
|
|
137
|
+
"--revisit",
|
|
138
|
+
type=int,
|
|
139
|
+
default=None,
|
|
140
|
+
metavar="DAYS",
|
|
141
|
+
help="only needs whose importance is undated or set more than DAYS ago — a refresh pass (#59)",
|
|
142
|
+
)
|
|
136
143
|
pl.set_defaults(func=cmd_plan)
|
|
137
144
|
|
|
138
145
|
ar = sub.add_parser("argument", help="show the GSN assurance graph and its defects")
|
|
@@ -37,6 +37,7 @@ from ..status import (
|
|
|
37
37
|
applicable_needs,
|
|
38
38
|
argument_defects,
|
|
39
39
|
evidence_age_days,
|
|
40
|
+
importance_revisit_due,
|
|
40
41
|
proof_suggestions,
|
|
41
42
|
recurrence,
|
|
42
43
|
relink_suggestions,
|
|
@@ -384,7 +385,20 @@ def cmd_brief(args: argparse.Namespace) -> int:
|
|
|
384
385
|
return 2
|
|
385
386
|
scoped = applicable_needs(needs, args.client)
|
|
386
387
|
results = resolve(scoped, cfg.adapters, cfg.source_adapters)
|
|
387
|
-
|
|
388
|
+
# "Confirm still wanted" (#58): needs whose evidence is older than the stale threshold, to
|
|
389
|
+
# re-check with the client in the meeting. Only when [lint].stale_after_days is configured.
|
|
390
|
+
stale: list[tuple[NeedResult, int]] = []
|
|
391
|
+
if cfg.stale_after_days is not None:
|
|
392
|
+
today = date.today()
|
|
393
|
+
for r in results:
|
|
394
|
+
age = evidence_age_days(r.need, today)
|
|
395
|
+
if age is not None and age > cfg.stale_after_days:
|
|
396
|
+
stale.append((r, age))
|
|
397
|
+
print(
|
|
398
|
+
brief_json(args.client, results, stale)
|
|
399
|
+
if args.json
|
|
400
|
+
else render_brief(args.client, results, stale)
|
|
401
|
+
)
|
|
388
402
|
return 0
|
|
389
403
|
|
|
390
404
|
|
|
@@ -397,10 +411,14 @@ def cmd_plan(args: argparse.Namespace) -> int:
|
|
|
397
411
|
needs = board_needs(list(model.needs), include_drafts=args.include_drafts) # drafts off (#60)
|
|
398
412
|
results = resolve(needs, cfg.adapters, cfg.source_adapters)
|
|
399
413
|
recurrence_counts = {rec.baseline.id: rec.count for rec in recurrence(needs)}
|
|
414
|
+
revisit = args.revisit is not None
|
|
415
|
+
if revisit:
|
|
416
|
+
today = date.today()
|
|
417
|
+
results = [r for r in results if importance_revisit_due(r.need, today, args.revisit)]
|
|
400
418
|
print(
|
|
401
|
-
plan_json(results, recurrence_counts)
|
|
419
|
+
plan_json(results, recurrence_counts, revisit=revisit)
|
|
402
420
|
if args.json
|
|
403
|
-
else render_plan(results, recurrence_counts)
|
|
421
|
+
else render_plan(results, recurrence_counts, revisit=revisit)
|
|
404
422
|
)
|
|
405
423
|
return 0
|
|
406
424
|
|
|
@@ -1090,7 +1108,9 @@ def _cmd_relink(cfg: Config, results: list[NeedResult], args: argparse.Namespace
|
|
|
1090
1108
|
print(f"{s.need.id}: {s.need.text}")
|
|
1091
1109
|
last = s.need.id
|
|
1092
1110
|
print(f" {s.score:.2f} {s.broken} → {s.locator}")
|
|
1093
|
-
print(
|
|
1111
|
+
print(
|
|
1112
|
+
f"suggest --relink: {len(suggestions)} candidate(s) at min-score {args.min_score:.2f}"
|
|
1113
|
+
)
|
|
1094
1114
|
return 0
|
|
1095
1115
|
|
|
1096
1116
|
|
|
@@ -1136,7 +1156,9 @@ def _apply_relinks(
|
|
|
1136
1156
|
else:
|
|
1137
1157
|
verb = "would relink" if args.dry_run else "relinked"
|
|
1138
1158
|
for s, edit in applied:
|
|
1139
|
-
print(
|
|
1159
|
+
print(
|
|
1160
|
+
f"{verb} {s.broken} → {s.locator} on {s.need.id} ({s.score:.2f}) in {edit.path.name}"
|
|
1161
|
+
)
|
|
1140
1162
|
print(edit.diff, end="")
|
|
1141
1163
|
print(f"suggest --relink --apply: {verb} {len(applied)} locator(s)")
|
|
1142
1164
|
return 0
|
|
@@ -331,7 +331,11 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
331
331
|
return "\n".join(lines)
|
|
332
332
|
|
|
333
333
|
|
|
334
|
-
def render_brief(
|
|
334
|
+
def render_brief(
|
|
335
|
+
client: str,
|
|
336
|
+
results: list[NeedResult],
|
|
337
|
+
stale: list[tuple[NeedResult, int]] | None = None,
|
|
338
|
+
) -> str:
|
|
335
339
|
open_ = brief_open(results)
|
|
336
340
|
out = [f"brief — {client}: {len(open_)} open question{'s' if len(open_) != 1 else ''}"]
|
|
337
341
|
if not open_:
|
|
@@ -347,6 +351,10 @@ def render_brief(client: str, results: list[NeedResult]) -> str:
|
|
|
347
351
|
if g
|
|
348
352
|
)
|
|
349
353
|
out.append(f" {bang} {r.need.id:<12} [{gaps}] {r.need.text}")
|
|
354
|
+
if stale:
|
|
355
|
+
out.append(f" confirm still wanted ({len(stale)} with evidence past the stale threshold):")
|
|
356
|
+
for r, age in sorted(stale, key=lambda t: (-t[1], t[0].need.id)):
|
|
357
|
+
out.append(f" ~ {r.need.id:<12} {age}d old {r.need.text}")
|
|
350
358
|
return "\n".join(out)
|
|
351
359
|
|
|
352
360
|
|
|
@@ -359,9 +367,16 @@ def _importance_cell(need: Need) -> str:
|
|
|
359
367
|
return f"imp={need.importance}{' (guess)' if weak else ''}"
|
|
360
368
|
|
|
361
369
|
|
|
362
|
-
def render_plan(
|
|
370
|
+
def render_plan(
|
|
371
|
+
results: list[NeedResult], recurrence_counts: dict[str, int], *, revisit: bool = False
|
|
372
|
+
) -> str:
|
|
363
373
|
ranked = rank(results, recurrence_counts)
|
|
364
|
-
|
|
374
|
+
header = (
|
|
375
|
+
f"plan — revisit: {len(ranked)} need(s) with undated or stale importance (#59)"
|
|
376
|
+
if revisit
|
|
377
|
+
else "plan — ranked backlog (must + state + evidence + recurrence, importance refines)"
|
|
378
|
+
)
|
|
379
|
+
out = [header]
|
|
365
380
|
for i, r in enumerate(ranked, 1):
|
|
366
381
|
bang = "!!" if r.need.must else " "
|
|
367
382
|
imp = _importance_cell(r.need)
|
|
@@ -130,7 +130,11 @@ def show_json(r: NeedResult) -> str:
|
|
|
130
130
|
return json.dumps(payload, indent=2)
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
def brief_json(
|
|
133
|
+
def brief_json(
|
|
134
|
+
client: str,
|
|
135
|
+
results: list[NeedResult],
|
|
136
|
+
stale: list[tuple[NeedResult, int]] | None = None,
|
|
137
|
+
) -> str:
|
|
134
138
|
open_ = brief_open(results)
|
|
135
139
|
return json.dumps(
|
|
136
140
|
{
|
|
@@ -146,15 +150,22 @@ def brief_json(client: str, results: list[NeedResult]) -> str:
|
|
|
146
150
|
}
|
|
147
151
|
for r in open_
|
|
148
152
|
],
|
|
153
|
+
"confirm_still_wanted": [
|
|
154
|
+
{"id": r.need.id, "text": r.need.text, "evidence_age": age}
|
|
155
|
+
for r, age in sorted(stale or [], key=lambda t: (-t[1], t[0].need.id))
|
|
156
|
+
],
|
|
149
157
|
},
|
|
150
158
|
indent=2,
|
|
151
159
|
)
|
|
152
160
|
|
|
153
161
|
|
|
154
|
-
def plan_json(
|
|
162
|
+
def plan_json(
|
|
163
|
+
results: list[NeedResult], recurrence_counts: dict[str, int], *, revisit: bool = False
|
|
164
|
+
) -> str:
|
|
155
165
|
ranked = rank(results, recurrence_counts)
|
|
156
166
|
return json.dumps(
|
|
157
167
|
{
|
|
168
|
+
"revisit": revisit,
|
|
158
169
|
"plan": [
|
|
159
170
|
{
|
|
160
171
|
"rank": i,
|
|
@@ -168,7 +179,7 @@ def plan_json(results: list[NeedResult], recurrence_counts: dict[str, int]) -> s
|
|
|
168
179
|
"importance_is_guess": (r.need.importance is not None and r.need.is_opinion),
|
|
169
180
|
}
|
|
170
181
|
for i, r in enumerate(ranked, 1)
|
|
171
|
-
]
|
|
182
|
+
],
|
|
172
183
|
},
|
|
173
184
|
indent=2,
|
|
174
185
|
)
|
|
@@ -272,7 +272,9 @@ class Config:
|
|
|
272
272
|
raise ConfigError("[lint].importance_revisit_days must be a positive integer")
|
|
273
273
|
draft_max_age = lint_tbl.get("draft_max_age_days")
|
|
274
274
|
if draft_max_age is not None and (
|
|
275
|
-
not isinstance(draft_max_age, int)
|
|
275
|
+
not isinstance(draft_max_age, int)
|
|
276
|
+
or isinstance(draft_max_age, bool)
|
|
277
|
+
or draft_max_age < 1
|
|
276
278
|
):
|
|
277
279
|
raise ConfigError("[lint].draft_max_age_days must be a positive integer")
|
|
278
280
|
|
|
@@ -150,7 +150,9 @@ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
|
|
|
150
150
|
kind = WithdrawalKind(kind_name)
|
|
151
151
|
except ValueError:
|
|
152
152
|
kinds = "|".join(k.value for k in WithdrawalKind)
|
|
153
|
-
raise LoaderError(
|
|
153
|
+
raise LoaderError(
|
|
154
|
+
f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})"
|
|
155
|
+
) from None
|
|
154
156
|
return Withdrawal(reason=reason, date=date, kind=kind)
|
|
155
157
|
|
|
156
158
|
|
|
@@ -74,7 +74,9 @@ class Source:
|
|
|
74
74
|
|
|
75
75
|
kind: SourceKind
|
|
76
76
|
who: str | None = None
|
|
77
|
-
role: str | None =
|
|
77
|
+
role: str | None = (
|
|
78
|
+
None # id of the Role the `who` spoke as — whose signal this is (#52 follow-up)
|
|
79
|
+
)
|
|
78
80
|
date: str | None = None
|
|
79
81
|
quote: str | None = None
|
|
80
82
|
locator: str | None = None
|
|
@@ -215,7 +217,9 @@ class Need:
|
|
|
215
217
|
release: str | None = None # id of the Release slice this need ships in; None = unslotted
|
|
216
218
|
product: bool = True # False = infra/NFR/internal, excluded from product coverage (#48)
|
|
217
219
|
draft: bool = False # True = staged, not yet counted on the board until promoted (#60)
|
|
218
|
-
draft_added: str | None =
|
|
220
|
+
draft_added: str | None = (
|
|
221
|
+
None # ISO date this need was staged (#60); lets a lint age-gate it later
|
|
222
|
+
)
|
|
219
223
|
withdrawn: Withdrawal | None = None # a decision against this need — off the board (#74)
|
|
220
224
|
answer: Answer | None = (
|
|
221
225
|
None # which way a settled need resolved (yes/no/inconclusive); None=open-ended
|
|
@@ -78,6 +78,24 @@ def evidence_age_days(need: Need, today: date) -> int | None:
|
|
|
78
78
|
return (today - max(dates)).days if dates else None
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def importance_revisit_due(need: Need, today: date, max_age_days: int) -> bool:
|
|
82
|
+
"""Whether a need's importance should be revisited — for ``plan --revisit`` (#59).
|
|
83
|
+
|
|
84
|
+
Due when the need has an importance set but it is either never dated (no ``importance_set_at``)
|
|
85
|
+
or last set more than ``max_age_days`` ago. A need with no importance is never due — there is
|
|
86
|
+
nothing to revisit. A malformed date is treated as not-due (the lint warning owns that defect).
|
|
87
|
+
"""
|
|
88
|
+
if need.importance is None:
|
|
89
|
+
return False
|
|
90
|
+
when = need.importance_set_at
|
|
91
|
+
if when is None:
|
|
92
|
+
return True
|
|
93
|
+
try:
|
|
94
|
+
return (today - date.fromisoformat(when)).days > max_age_days
|
|
95
|
+
except ValueError:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
81
99
|
def _resolve_proof(locator: str, adapters: dict[str, Adapter]) -> ProofResult:
|
|
82
100
|
name, sep, payload = locator.partition(":")
|
|
83
101
|
adapter = adapters.get(name)
|
|
@@ -164,3 +164,52 @@ needs:
|
|
|
164
164
|
payload = json.loads(capsys.readouterr().out)
|
|
165
165
|
n1 = next(n for n in payload["needs"] if n["id"] == "N-1")
|
|
166
166
|
assert n1["importance_set_at"] == "2026-10-01"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- plan --revisit (#59 follow-up) -----------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_plan_revisit_filters_to_undated_and_stale(
|
|
173
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
174
|
+
) -> None:
|
|
175
|
+
from prodc.cli import main
|
|
176
|
+
|
|
177
|
+
old = (date.today() - timedelta(days=400)).isoformat()
|
|
178
|
+
recent = (date.today() - timedelta(days=5)).isoformat()
|
|
179
|
+
needs = f"""
|
|
180
|
+
needs:
|
|
181
|
+
- id: UNDATED
|
|
182
|
+
text: importance set, never dated
|
|
183
|
+
importance: 2
|
|
184
|
+
- id: STALE
|
|
185
|
+
text: importance set long ago
|
|
186
|
+
importance: 3
|
|
187
|
+
importance_set_at: {old}
|
|
188
|
+
- id: FRESH
|
|
189
|
+
text: importance set recently
|
|
190
|
+
importance: 1
|
|
191
|
+
importance_set_at: {recent}
|
|
192
|
+
- id: NONE
|
|
193
|
+
text: no importance at all
|
|
194
|
+
"""
|
|
195
|
+
main(["plan", "--revisit", "30", "--config", str(_project(tmp_path, _TOML, needs)), "--json"])
|
|
196
|
+
payload = json.loads(capsys.readouterr().out)
|
|
197
|
+
assert payload["revisit"] is True
|
|
198
|
+
assert {p["id"] for p in payload["plan"]} == {"UNDATED", "STALE"}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_plan_without_revisit_lists_all(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
202
|
+
from prodc.cli import main
|
|
203
|
+
|
|
204
|
+
needs = """
|
|
205
|
+
needs:
|
|
206
|
+
- id: N-1
|
|
207
|
+
text: a thing
|
|
208
|
+
importance: 2
|
|
209
|
+
- id: N-2
|
|
210
|
+
text: another thing
|
|
211
|
+
"""
|
|
212
|
+
main(["plan", "--config", str(_project(tmp_path, _TOML, needs)), "--json"])
|
|
213
|
+
payload = json.loads(capsys.readouterr().out)
|
|
214
|
+
assert payload["revisit"] is False
|
|
215
|
+
assert {p["id"] for p in payload["plan"]} == {"N-1", "N-2"}
|
|
@@ -95,7 +95,7 @@ def test_relink_skips_when_adapter_absent(
|
|
|
95
95
|
) -> None:
|
|
96
96
|
# A locator whose adapter does not exist is BROKEN but has no inventory to relink against.
|
|
97
97
|
(tmp_path / "needs").mkdir(parents=True, exist_ok=True)
|
|
98
|
-
(tmp_path / "prodc.toml").write_text(
|
|
98
|
+
(tmp_path / "prodc.toml").write_text('[prodc]\nneeds = ["needs/*.yaml"]\n', encoding="utf-8")
|
|
99
99
|
(tmp_path / "needs" / "a.yaml").write_text(
|
|
100
100
|
"needs:\n - id: N-1\n text: t\n proofs: ['ghost:1']\n", encoding="utf-8"
|
|
101
101
|
)
|
|
@@ -58,7 +58,9 @@ def test_review_list_carries_staged_date_and_dup(
|
|
|
58
58
|
assert rows["D-2"]["duplicate"] is None
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def test_review_promote_sets_draft_false(
|
|
61
|
+
def test_review_promote_sets_draft_false(
|
|
62
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
63
|
+
) -> None:
|
|
62
64
|
cfg = _project(tmp_path)
|
|
63
65
|
code = main(["review", "--promote", "D-1", "--config", str(cfg)])
|
|
64
66
|
assert code == 0
|
|
@@ -113,7 +115,9 @@ def test_review_dry_run_writes_nothing(tmp_path: Path, capsys: pytest.CaptureFix
|
|
|
113
115
|
assert "dry-run" in capsys.readouterr().out
|
|
114
116
|
|
|
115
117
|
|
|
116
|
-
def test_review_preserves_header_comment(
|
|
118
|
+
def test_review_preserves_header_comment(
|
|
119
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
120
|
+
) -> None:
|
|
117
121
|
cfg = _project(tmp_path)
|
|
118
122
|
main(["review", "--drop", "D-2", "--config", str(cfg)])
|
|
119
123
|
capsys.readouterr()
|
|
@@ -13,7 +13,7 @@ from prodc.loader import LoaderError, load
|
|
|
13
13
|
from prodc.model import Need, Source, SourceKind
|
|
14
14
|
from prodc.writer import capture_source
|
|
15
15
|
|
|
16
|
-
_TOML =
|
|
16
|
+
_TOML = '[prodc]\nneeds = ["needs/*.yaml"]\n'
|
|
17
17
|
|
|
18
18
|
_NEEDS = """
|
|
19
19
|
roles:
|
|
@@ -152,3 +152,51 @@ needs:
|
|
|
152
152
|
assert code == 0
|
|
153
153
|
written = (tmp_path / "needs" / "a.yaml").read_text(encoding="utf-8")
|
|
154
154
|
assert "observed_at:" in written and "2026-10-05" in written
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- brief "confirm still wanted" (#58 follow-up) ---------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _client_needs() -> str:
|
|
161
|
+
old = (date.today() - timedelta(days=400)).isoformat()
|
|
162
|
+
fresh = date.today().isoformat()
|
|
163
|
+
return f"""
|
|
164
|
+
needs:
|
|
165
|
+
- id: OLD-1
|
|
166
|
+
text: acme wants the old thing
|
|
167
|
+
client: acme
|
|
168
|
+
sources:
|
|
169
|
+
- kind: primary
|
|
170
|
+
who: Ana
|
|
171
|
+
quote: "I need this"
|
|
172
|
+
observed_at: {old}
|
|
173
|
+
- id: FRESH-1
|
|
174
|
+
text: acme wants the fresh thing
|
|
175
|
+
client: acme
|
|
176
|
+
sources:
|
|
177
|
+
- kind: primary
|
|
178
|
+
who: Bob
|
|
179
|
+
quote: "me too"
|
|
180
|
+
observed_at: {fresh}
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_brief_confirm_still_wanted(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
185
|
+
from prodc.cli import main
|
|
186
|
+
|
|
187
|
+
toml = _TOML + "\n[lint]\nstale_after_days = 180\n"
|
|
188
|
+
main(["brief", "acme", "--config", str(_project(tmp_path, toml, _client_needs())), "--json"])
|
|
189
|
+
payload = json.loads(capsys.readouterr().out)
|
|
190
|
+
stale = {s["id"] for s in payload["confirm_still_wanted"]}
|
|
191
|
+
assert stale == {"OLD-1"} # only the >180d evidence is up for re-confirmation
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_brief_no_confirm_section_without_knob(
|
|
195
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
196
|
+
) -> None:
|
|
197
|
+
from prodc.cli import main
|
|
198
|
+
|
|
199
|
+
# no [lint].stale_after_days configured -> nothing to re-confirm
|
|
200
|
+
main(["brief", "acme", "--config", str(_project(tmp_path, _TOML, _client_needs())), "--json"])
|
|
201
|
+
payload = json.loads(capsys.readouterr().out)
|
|
202
|
+
assert payload["confirm_still_wanted"] == []
|
|
@@ -306,8 +306,18 @@ def test_withdraw_writes_non_default_kind(tmp_path: Path) -> None:
|
|
|
306
306
|
|
|
307
307
|
cfg = _project(tmp_path)
|
|
308
308
|
code = main(
|
|
309
|
-
[
|
|
310
|
-
|
|
309
|
+
[
|
|
310
|
+
"withdraw",
|
|
311
|
+
"N-1",
|
|
312
|
+
"--reason",
|
|
313
|
+
"off strategy",
|
|
314
|
+
"--kind",
|
|
315
|
+
"out_of_scope",
|
|
316
|
+
"--date",
|
|
317
|
+
"2026-10-15",
|
|
318
|
+
"--config",
|
|
319
|
+
str(cfg),
|
|
320
|
+
]
|
|
311
321
|
)
|
|
312
322
|
assert code == 0
|
|
313
323
|
by_id = {n.id: n for n in load(Config.load(cfg)).needs}
|
|
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
|