prodc 0.4.4__tar.gz → 0.4.6__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.4.4 → prodc-0.4.6}/PKG-INFO +1 -1
- {prodc-0.4.4 → prodc-0.4.6}/pyproject.toml +1 -1
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/board.py +3 -1
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/app.py +22 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/commands.py +36 -5
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/render.py +25 -1
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/dedup.py +32 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/loader.py +20 -2
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/model.py +27 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/report.py +5 -5
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/status.py +47 -4
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/writer.py +7 -1
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_dedup.py +18 -1
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_loader.py +10 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_principles.py +77 -2
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_writer.py +8 -0
- {prodc-0.4.4 → prodc-0.4.6}/.gitignore +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/CHANGELOG.md +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/LICENSE +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/README.md +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/__init__.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/adapters.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/agent.md +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/config.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/guide.md +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/py.typed +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/src/prodc/report_html.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_adapters.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_answer.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_callbacks.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_citation.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_cli.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_command_hook.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_contradicts.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_decisions.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_description.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_drafts.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_evalgate.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_importance.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_models.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_needref.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_relink.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_report.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_report_html.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_review.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_roles.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_source_role.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_sources.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_staleness.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_status.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_suggest.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_variants.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_who_variants.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_withdrawn.py +0 -0
- {prodc-0.4.4 → prodc-0.4.6}/tests/test_writer_comments.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
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
|
|
@@ -115,6 +115,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
115
115
|
),
|
|
116
116
|
"proofs": list(need.proofs),
|
|
117
117
|
"contradicts": list(need.contradicts),
|
|
118
|
+
"tags": list(need.tags),
|
|
118
119
|
"detail": list(r.detail),
|
|
119
120
|
"warnings": list(r.warnings),
|
|
120
121
|
"attestation": (
|
|
@@ -157,6 +158,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
|
|
|
157
158
|
"product": need.product,
|
|
158
159
|
"draft": need.draft,
|
|
159
160
|
"contradicts": list(need.contradicts),
|
|
161
|
+
"tags": list(need.tags),
|
|
160
162
|
"answer": need.answer.value if need.answer else None,
|
|
161
163
|
"withdrawn": (
|
|
162
164
|
{
|
|
@@ -248,5 +250,5 @@ def gather(
|
|
|
248
250
|
handoffs=handoffs(use_needs),
|
|
249
251
|
recurrence=recurrence(use_needs),
|
|
250
252
|
orphans=orphans(use_needs, cfg.adapters),
|
|
251
|
-
principles=principle_report(list(model.needs), list(model.principles)),
|
|
253
|
+
principles=principle_report(list(model.needs), list(model.principles), cfg.adapters),
|
|
252
254
|
)
|
|
@@ -67,6 +67,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
67
67
|
metavar="ID",
|
|
68
68
|
help="scope the board + coverage to the needs serving one goal",
|
|
69
69
|
)
|
|
70
|
+
st.add_argument(
|
|
71
|
+
"--tag",
|
|
72
|
+
default=None,
|
|
73
|
+
metavar="TAG",
|
|
74
|
+
help="scope the board + coverage to the needs carrying one tag (#80)",
|
|
75
|
+
)
|
|
70
76
|
st.add_argument(
|
|
71
77
|
"--columns",
|
|
72
78
|
default=None,
|
|
@@ -188,6 +194,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
188
194
|
help="principle only: its scope — product (default), design, visual, engineering; "
|
|
189
195
|
"must be declared in [principles].scopes",
|
|
190
196
|
)
|
|
197
|
+
ad.add_argument(
|
|
198
|
+
"--implemented-in",
|
|
199
|
+
default=None,
|
|
200
|
+
metavar="LOCATOR",
|
|
201
|
+
dest="implemented_in",
|
|
202
|
+
help="principle only: a locator to the one place its values live (a pointer, not a copy)",
|
|
203
|
+
)
|
|
204
|
+
ad.add_argument(
|
|
205
|
+
"--proof",
|
|
206
|
+
action="append",
|
|
207
|
+
default=None,
|
|
208
|
+
dest="principle_proofs",
|
|
209
|
+
metavar="LOCATOR",
|
|
210
|
+
help="principle only: a guard-test locator proving the code obeys it (repeatable); "
|
|
211
|
+
"never a need: reference",
|
|
212
|
+
)
|
|
191
213
|
ad.add_argument(
|
|
192
214
|
"--needs",
|
|
193
215
|
default=None,
|
|
@@ -24,11 +24,12 @@ from ..dedup import (
|
|
|
24
24
|
VariantGroup,
|
|
25
25
|
duplicate_pairs,
|
|
26
26
|
matches_for,
|
|
27
|
+
tag_warnings,
|
|
27
28
|
variant_suggestions,
|
|
28
29
|
who_variants,
|
|
29
30
|
)
|
|
30
31
|
from ..loader import LoaderError, load
|
|
31
|
-
from ..model import Need, NeedResult, NeedState, board_needs
|
|
32
|
+
from ..model import Need, NeedResult, NeedState, PrincipleState, board_needs
|
|
32
33
|
from ..report import render_markdown
|
|
33
34
|
from ..report_html import render_html
|
|
34
35
|
from ..status import (
|
|
@@ -229,14 +230,31 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
229
230
|
_annotate_draft_age_warnings(results, cfg)
|
|
230
231
|
# The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
|
|
231
232
|
# comes back up — the frame it was judged against has changed since. A warning, never a hard
|
|
232
|
-
# error; prodc flags, the PM re-decides.
|
|
233
|
-
|
|
233
|
+
# error; prodc flags, the PM re-decides. The same loop grades each principle's guards (#89): a
|
|
234
|
+
# BROKEN guard/implemented_in is a hard error (a dangling locator), a VIOLATED guard warns (the
|
|
235
|
+
# code contradicts a stated principle — surfaced, but the PM owns the reconcile).
|
|
236
|
+
principle_errors: list[str] = []
|
|
237
|
+
principle_warnings: list[tuple[str, str]] = []
|
|
238
|
+
for rep in principle_report(needs, list(model.principles), cfg.adapters):
|
|
234
239
|
for n in rep.stale:
|
|
235
240
|
if n.id in by_id and n.withdrawn is not None:
|
|
236
241
|
by_id[n.id].warnings.append(
|
|
237
242
|
f"withdrawn per principle {rep.principle.id} on {n.withdrawn.date}, but "
|
|
238
243
|
f"{rep.principle.id} was revised {rep.principle.revised_at} — re-review the decision"
|
|
239
244
|
)
|
|
245
|
+
if rep.state is PrincipleState.BROKEN:
|
|
246
|
+
principle_errors.append(
|
|
247
|
+
f"{rep.principle.id}: BROKEN principle guard — a proof or implemented_in locator "
|
|
248
|
+
"resolves to nothing"
|
|
249
|
+
)
|
|
250
|
+
elif rep.state is PrincipleState.VIOLATED:
|
|
251
|
+
principle_warnings.append(
|
|
252
|
+
(
|
|
253
|
+
rep.principle.id,
|
|
254
|
+
"VIOLATED — a guard failed: the code contradicts this principle; reconcile the "
|
|
255
|
+
"code or revise the principle",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
240
258
|
# Unresolved contradiction: two needs oppose each other yet BOTH are still live — the product
|
|
241
259
|
# vision has not decided between them. A warning on both sides (withdraw one out_of_scope citing
|
|
242
260
|
# the principle that decides, or reconcile them).
|
|
@@ -249,10 +267,10 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
249
267
|
f"unresolved contradiction with {far.id} — both live, the vision has not "
|
|
250
268
|
"decided yet (withdraw one out_of_scope citing the principle, or reconcile)"
|
|
251
269
|
)
|
|
252
|
-
errors = _lint_errors(results)
|
|
270
|
+
errors = _lint_errors(results) + principle_errors
|
|
253
271
|
warnings = [
|
|
254
272
|
(r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
|
|
255
|
-
]
|
|
273
|
+
] + principle_warnings
|
|
256
274
|
# Decision drift (keyed by the decision id, not a need): a decision left serving only
|
|
257
275
|
# withdrawn/non-product needs is engineering with no live product reason; a decision decided
|
|
258
276
|
# under a principle that was revised later comes back up (the same ripple a withdrawal gets).
|
|
@@ -271,6 +289,15 @@ def cmd_lint(args: argparse.Namespace) -> int:
|
|
|
271
289
|
f"decided {d.decided_at} under principle {pid}, revised later — re-review this solution",
|
|
272
290
|
)
|
|
273
291
|
)
|
|
292
|
+
# Tag hygiene (#80): the flat tag space is undeclared, so flag singletons, near-duplicate
|
|
293
|
+
# spellings, and a tag standing in for a declared goal/role/release/client (structure hiding).
|
|
294
|
+
structural_ids = (
|
|
295
|
+
{g.id for g in model.goals}
|
|
296
|
+
| {p.id for p in model.roles}
|
|
297
|
+
| {r.id for r in model.releases}
|
|
298
|
+
| {n.client for n in needs if n.client is not None}
|
|
299
|
+
)
|
|
300
|
+
warnings += tag_warnings(needs, structural_ids, cfg.duplicate_threshold)
|
|
274
301
|
if args.json:
|
|
275
302
|
print(
|
|
276
303
|
json.dumps(
|
|
@@ -314,6 +341,8 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|
|
314
341
|
needs = applicable_needs(needs, args.client)
|
|
315
342
|
if args.goal:
|
|
316
343
|
needs = [n for n in needs if n.goal == args.goal]
|
|
344
|
+
if args.tag:
|
|
345
|
+
needs = [n for n in needs if args.tag in n.tags]
|
|
317
346
|
if args.stale is not None:
|
|
318
347
|
today = date.today()
|
|
319
348
|
needs = [
|
|
@@ -558,6 +587,8 @@ def cmd_add(args: argparse.Namespace) -> int:
|
|
|
558
587
|
file=args.file,
|
|
559
588
|
revised_at=date.today().isoformat(),
|
|
560
589
|
scope=args.scope,
|
|
590
|
+
implemented_in=args.implemented_in,
|
|
591
|
+
proofs=tuple(args.principle_proofs or ()),
|
|
561
592
|
sources=sources,
|
|
562
593
|
expect_sha=args.expect_sha,
|
|
563
594
|
dry_run=args.dry_run,
|
|
@@ -18,6 +18,7 @@ from ..model import (
|
|
|
18
18
|
Need,
|
|
19
19
|
NeedResult,
|
|
20
20
|
NeedState,
|
|
21
|
+
PrincipleState,
|
|
21
22
|
Release,
|
|
22
23
|
Role,
|
|
23
24
|
ears_shape,
|
|
@@ -290,15 +291,36 @@ def render_principles(reports: list[PrincipleReport]) -> str:
|
|
|
290
291
|
a = rep.principle
|
|
291
292
|
rev = f"revised {a.revised_at}" if a.revised_at else "undated"
|
|
292
293
|
scope = "" if a.scope == "product" else f"[{a.scope}] "
|
|
293
|
-
|
|
294
|
+
flag = "⚠ " if rep.state in (PrincipleState.VIOLATED, PrincipleState.BROKEN) else ""
|
|
295
|
+
out.append(f" {a.id:<6} {flag}{rep.state.value.upper():<9} {scope}{rev} {a.text}")
|
|
296
|
+
if a.implemented_in:
|
|
297
|
+
out.append(f" implemented in: {a.implemented_in}")
|
|
294
298
|
if rep.declined:
|
|
295
299
|
out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
|
|
296
300
|
if rep.stale:
|
|
297
301
|
ids = ", ".join(n.id for n in rep.stale)
|
|
298
302
|
out.append(f" ⚠ decided before the latest revision: {ids} — re-review")
|
|
303
|
+
tally = _principle_tally(reports)
|
|
304
|
+
if tally:
|
|
305
|
+
out.append(f" — {tally}")
|
|
299
306
|
return "\n".join(out)
|
|
300
307
|
|
|
301
308
|
|
|
309
|
+
def _principle_tally(reports: list[PrincipleReport]) -> str:
|
|
310
|
+
"""A per-scope ENFORCED/STATED/… count line, scopes and states in declared enum order (#89)."""
|
|
311
|
+
by_scope: dict[str, dict[PrincipleState, int]] = {}
|
|
312
|
+
for rep in reports:
|
|
313
|
+
counts = by_scope.setdefault(rep.principle.scope, {})
|
|
314
|
+
counts[rep.state] = counts.get(rep.state, 0) + 1
|
|
315
|
+
parts: list[str] = []
|
|
316
|
+
for scope, counts in by_scope.items():
|
|
317
|
+
inner = ", ".join(
|
|
318
|
+
f"{counts[s]} {s.value}" for s in PrincipleState if counts.get(s)
|
|
319
|
+
)
|
|
320
|
+
parts.append(f"{scope}: {inner}")
|
|
321
|
+
return " · ".join(parts)
|
|
322
|
+
|
|
323
|
+
|
|
302
324
|
def render_contradictions(contras: list[Contradiction]) -> str:
|
|
303
325
|
"""Unresolved contradictions on the board: two needs in tension the vision has not decided."""
|
|
304
326
|
out = ["\ncontradictions (two needs in tension — the vision has not decided):"]
|
|
@@ -400,6 +422,8 @@ def render_show(
|
|
|
400
422
|
lines.append(f" children: {rendered}")
|
|
401
423
|
if need.contradicts:
|
|
402
424
|
lines.append(f" contradicts: {', '.join(need.contradicts)}")
|
|
425
|
+
if need.tags:
|
|
426
|
+
lines.append(f" tags: {', '.join(need.tags)}")
|
|
403
427
|
if need.seam:
|
|
404
428
|
lines.append(f" seam: {', '.join(need.seam)}")
|
|
405
429
|
if need.proofs:
|
|
@@ -237,3 +237,35 @@ def who_variants(needs: list[Need], threshold: float) -> list[WhoVariant]:
|
|
|
237
237
|
out.append(WhoVariant(client, tuple(sorted(members)), tuple(ids)))
|
|
238
238
|
out.sort(key=lambda v: (v.client or "", v.whos))
|
|
239
239
|
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def tag_warnings(
|
|
243
|
+
needs: list[Need], structural_ids: set[str], threshold: float
|
|
244
|
+
) -> list[tuple[str, str]]:
|
|
245
|
+
"""Keep the flat tag space from rotting (#80): singletons, near-dup spellings, hidden structure.
|
|
246
|
+
|
|
247
|
+
Tags are undeclared and many-to-many, so nothing stops a typo (`search` vs `searching`) or a
|
|
248
|
+
one-off from accumulating. Three cheap hygiene signals, each a warning keyed by the tag:
|
|
249
|
+
a tag equal to a declared goal/role/release/client id ("structure hiding in a tag" — use the
|
|
250
|
+
first-class field instead), a singleton (one need only — a likely typo or a tag not worth it),
|
|
251
|
+
and a near-duplicate spelling of another tag (same :func:`similarity` the dedup checks use).
|
|
252
|
+
"""
|
|
253
|
+
counts: dict[str, int] = {}
|
|
254
|
+
for n in needs:
|
|
255
|
+
for t in n.tags:
|
|
256
|
+
counts[t] = counts.get(t, 0) + 1
|
|
257
|
+
out: list[tuple[str, str]] = []
|
|
258
|
+
tags = sorted(counts)
|
|
259
|
+
for t in tags:
|
|
260
|
+
if t in structural_ids:
|
|
261
|
+
out.append(
|
|
262
|
+
(t, "tag equals a declared goal/role/release/client id — use that field, not a tag")
|
|
263
|
+
)
|
|
264
|
+
elif counts[t] == 1:
|
|
265
|
+
out.append((t, "tag used on only one need — a singleton (likely a typo or not worth a tag)"))
|
|
266
|
+
for i in range(len(tags)):
|
|
267
|
+
for j in range(i + 1, len(tags)):
|
|
268
|
+
a, b = tags[i], tags[j]
|
|
269
|
+
if _normalise(a) != _normalise(b) and similarity(a, b) >= threshold:
|
|
270
|
+
out.append((a, f"tag may be a near-duplicate spelling of {b!r}"))
|
|
271
|
+
return out
|
|
@@ -278,6 +278,10 @@ def _need(
|
|
|
278
278
|
_as_str(c, f"{where}.contradicts[{i}]")
|
|
279
279
|
for i, c in enumerate(_as_list(tbl.get("contradicts"), f"{where}.contradicts"))
|
|
280
280
|
)
|
|
281
|
+
tags = tuple(
|
|
282
|
+
_as_str(t, f"{where}.tags[{i}]")
|
|
283
|
+
for i, t in enumerate(_as_list(tbl.get("tags"), f"{where}.tags"))
|
|
284
|
+
)
|
|
281
285
|
if children and proofs:
|
|
282
286
|
raise LoaderError(
|
|
283
287
|
f"{where} ({nid}): a flow (need with children) proves its steps via the "
|
|
@@ -308,6 +312,7 @@ def _need(
|
|
|
308
312
|
not_duplicate_of=not_duplicate_of,
|
|
309
313
|
not_variant_of=not_variant_of,
|
|
310
314
|
contradicts=contradicts,
|
|
315
|
+
tags=tags,
|
|
311
316
|
importance_set_at=importance_set_at,
|
|
312
317
|
draft=draft,
|
|
313
318
|
draft_added=draft_added,
|
|
@@ -344,10 +349,21 @@ def _role(raw: object, where: str) -> Role:
|
|
|
344
349
|
|
|
345
350
|
|
|
346
351
|
def _principle(raw: object, where: str) -> Principle:
|
|
347
|
-
"""Parse one ``principles:`` entry — a product commitment with its provenance."""
|
|
352
|
+
"""Parse one ``principles:`` entry — a product commitment with its provenance and guards."""
|
|
348
353
|
tbl = _as_mapping(raw, where)
|
|
354
|
+
pid = _as_str(tbl.get("id"), f"{where}.id")
|
|
355
|
+
proofs = tuple(
|
|
356
|
+
_as_str(p, f"{where}.proofs[{i}]")
|
|
357
|
+
for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
|
|
358
|
+
)
|
|
359
|
+
for p in proofs:
|
|
360
|
+
if p.split(":", 1)[0] == "need":
|
|
361
|
+
raise LoaderError(
|
|
362
|
+
f"principle {pid!r}: proof {p!r} is a need reference — a principle guards the "
|
|
363
|
+
"code, it does not wait on a need to settle (that inverts the OST)"
|
|
364
|
+
)
|
|
349
365
|
return Principle(
|
|
350
|
-
id=
|
|
366
|
+
id=pid,
|
|
351
367
|
text=_as_str(tbl.get("text"), f"{where}.text"),
|
|
352
368
|
scope=_opt_str(tbl.get("scope"), f"{where}.scope") or "product",
|
|
353
369
|
revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
|
|
@@ -355,6 +371,8 @@ def _principle(raw: object, where: str) -> Principle:
|
|
|
355
371
|
_source(s, f"{where}.sources[{i}]")
|
|
356
372
|
for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
|
|
357
373
|
),
|
|
374
|
+
implemented_in=_opt_str(tbl.get("implemented_in"), f"{where}.implemented_in"),
|
|
375
|
+
proofs=proofs,
|
|
358
376
|
)
|
|
359
377
|
|
|
360
378
|
|
|
@@ -48,6 +48,23 @@ class NeedState(StrEnum):
|
|
|
48
48
|
UNRESOLVED = "unresolved" # a proof's source could not be read (e.g. sibling repo absent)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class PrincipleState(StrEnum):
|
|
52
|
+
"""Whether a principle's proofs show the code still obeys it (#89).
|
|
53
|
+
|
|
54
|
+
A principle proof is a *guard against regression*, the mirror of a need proof's *progress toward*
|
|
55
|
+
an outcome: a failing need proof is "not yet", but a failing principle guard is "the code
|
|
56
|
+
contradicts this stance right now" — exactly the tokens.css event where a reversed rule's old
|
|
57
|
+
wording lived on for months. So a failing guard is VIOLATED, never merely PENDING, and the board
|
|
58
|
+
cannot read a contradicted principle as green.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
ENFORCED = "enforced" # every proof resolved green — the code demonstrably obeys it
|
|
62
|
+
STATED = "stated" # no active guard (no proofs, or only declared-pending ones) — honest, allowed
|
|
63
|
+
VIOLATED = "violated" # a guard ran and failed — the code contradicts the stance now
|
|
64
|
+
BROKEN = "broken" # a proof or implemented_in locator resolves to nothing — lint fails
|
|
65
|
+
UNRESOLVED = "unresolved" # a proof's source could not be read (e.g. sibling repo absent)
|
|
66
|
+
|
|
67
|
+
|
|
51
68
|
class Answer(StrEnum):
|
|
52
69
|
"""Which way a settled question resolved — the research board's direction axis.
|
|
53
70
|
|
|
@@ -169,6 +186,13 @@ class Principle:
|
|
|
169
186
|
and code comments, where a Decision or an ``out_of_scope`` withdrawal cannot cite them. The scope
|
|
170
187
|
must be one declared in ``[principles].scopes`` (default ``["product"]``); an undeclared scope is a
|
|
171
188
|
load error the author owns.
|
|
189
|
+
|
|
190
|
+
``proofs`` tie the principle to the guard tests that prove the code still obeys it, resolved by
|
|
191
|
+
the same adapters as a need's proofs, and ``implemented_in`` points at the one place the values
|
|
192
|
+
live (e.g. a tokens file) — a pointer, not a copy, so the hex/font values stay in code. Together
|
|
193
|
+
they give the principle a :class:`PrincipleState` (ENFORCED / STATED / VIOLATED / BROKEN); a
|
|
194
|
+
principle may not carry a ``need:`` proof — it guards the code, it does not wait on a need to
|
|
195
|
+
settle — and it still never enters coverage or the plan.
|
|
172
196
|
"""
|
|
173
197
|
|
|
174
198
|
id: str
|
|
@@ -176,6 +200,8 @@ class Principle:
|
|
|
176
200
|
scope: str = "product" # product/design/visual/engineering — declared in [principles].scopes (#88)
|
|
177
201
|
revised_at: str | None = None # ISO date the principle was last stated or revised
|
|
178
202
|
sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
|
|
203
|
+
implemented_in: str | None = None # locator to the ONE place the values live; a pointer, not a copy (#89)
|
|
204
|
+
proofs: tuple[str, ...] = () # guard-test locators, resolved by the same adapters as needs (#89)
|
|
179
205
|
|
|
180
206
|
|
|
181
207
|
@dataclass(frozen=True)
|
|
@@ -324,6 +350,7 @@ class Need:
|
|
|
324
350
|
contradicts: tuple[
|
|
325
351
|
str, ...
|
|
326
352
|
] = () # ids of needs this one opposes; symmetric, a principle decides
|
|
353
|
+
tags: tuple[str, ...] = () # flat, many-to-many, stateless labels — a filter axis, not a group (#80)
|
|
327
354
|
|
|
328
355
|
@property
|
|
329
356
|
def is_variant(self) -> bool:
|
|
@@ -14,7 +14,7 @@ from datetime import date
|
|
|
14
14
|
from .board import gather
|
|
15
15
|
from .config import Config
|
|
16
16
|
from .model import Model, NeedResult, NeedState
|
|
17
|
-
from .status import active_decisions, group_state,
|
|
17
|
+
from .status import active_decisions, group_state, state_priority
|
|
18
18
|
|
|
19
19
|
_LABEL = {
|
|
20
20
|
NeedState.DELIVERED: "DELIVERED",
|
|
@@ -156,15 +156,15 @@ def render_markdown(
|
|
|
156
156
|
out += [
|
|
157
157
|
"## Principles (the vision — held as given)",
|
|
158
158
|
"",
|
|
159
|
-
"| principle | scope | stated | needs declined under it |",
|
|
160
|
-
"
|
|
159
|
+
"| principle | scope | state | stated | needs declined under it |",
|
|
160
|
+
"|---|---|---|---|---|",
|
|
161
161
|
]
|
|
162
|
-
for rep in
|
|
162
|
+
for rep in board.principles:
|
|
163
163
|
text = rep.principle.text.replace("|", "\\|")
|
|
164
164
|
declined = ", ".join(n.id for n in rep.declined) or "—"
|
|
165
165
|
stale = f" _(re-review {', '.join(n.id for n in rep.stale)})_" if rep.stale else ""
|
|
166
166
|
out.append(
|
|
167
|
-
f"| {rep.principle.id}: {text} | {rep.principle.scope} "
|
|
167
|
+
f"| {rep.principle.id}: {text} | {rep.principle.scope} | {rep.state.value.upper()} "
|
|
168
168
|
f"| {rep.principle.revised_at or '—'} | {declined}{stale} |"
|
|
169
169
|
)
|
|
170
170
|
out.append("")
|
|
@@ -23,6 +23,7 @@ from .model import (
|
|
|
23
23
|
NeedResult,
|
|
24
24
|
NeedState,
|
|
25
25
|
Principle,
|
|
26
|
+
PrincipleState,
|
|
26
27
|
ProofState,
|
|
27
28
|
Release,
|
|
28
29
|
Role,
|
|
@@ -658,13 +659,48 @@ def role_report(
|
|
|
658
659
|
return coverage, unroled
|
|
659
660
|
|
|
660
661
|
|
|
662
|
+
def principle_state(principle: Principle, adapters: dict[str, Adapter]) -> PrincipleState:
|
|
663
|
+
"""Resolve a principle's guards into its enforced/stated/violated/broken state (#89).
|
|
664
|
+
|
|
665
|
+
A principle proof is a guard against regression, so its ``ProofState`` reads differently from a
|
|
666
|
+
need's (:class:`~prodc.model.PrincipleState` says why). Using :attr:`ProofResult.declared` to tell
|
|
667
|
+
a declared-but-inactive guard from one that actually ran and failed:
|
|
668
|
+
|
|
669
|
+
- ``implemented_in`` set but not resolving, or any proof BROKEN → **BROKEN** (lint fails).
|
|
670
|
+
- any proof UNRESOLVED → **UNRESOLVED** (a source could not be read).
|
|
671
|
+
- a proof PENDING with ``declared=False`` (a results artifact shows it failing) → **VIOLATED**.
|
|
672
|
+
- no proofs, or only DELIVERED-or-declared-pending guards and not all green → **STATED** (honest).
|
|
673
|
+
- every proof DELIVERED → **ENFORCED**.
|
|
674
|
+
|
|
675
|
+
``implemented_in`` is a pointer, not evidence: it can only make a principle BROKEN (a dangling
|
|
676
|
+
locator), never ENFORCED.
|
|
677
|
+
"""
|
|
678
|
+
impl_broken = (
|
|
679
|
+
principle.implemented_in is not None
|
|
680
|
+
and _resolve_proof(principle.implemented_in, adapters).state is ProofState.BROKEN
|
|
681
|
+
)
|
|
682
|
+
results = [_resolve_proof(loc, adapters) for loc in principle.proofs]
|
|
683
|
+
if not principle.proofs:
|
|
684
|
+
return PrincipleState.BROKEN if impl_broken else PrincipleState.STATED
|
|
685
|
+
if impl_broken or any(r.state is ProofState.BROKEN for r in results):
|
|
686
|
+
return PrincipleState.BROKEN
|
|
687
|
+
if any(r.state is ProofState.UNRESOLVED for r in results):
|
|
688
|
+
return PrincipleState.UNRESOLVED
|
|
689
|
+
if any(r.state is ProofState.PENDING and not r.declared for r in results):
|
|
690
|
+
return PrincipleState.VIOLATED # a guard ran and failed — the code contradicts the stance
|
|
691
|
+
if all(r.state is ProofState.DELIVERED for r in results):
|
|
692
|
+
return PrincipleState.ENFORCED
|
|
693
|
+
return PrincipleState.STATED # some declared-pending guards, none failing — honestly not yet active
|
|
694
|
+
|
|
695
|
+
|
|
661
696
|
@dataclass(frozen=True)
|
|
662
697
|
class PrincipleReport:
|
|
663
|
-
"""One principle and the decisions that rest on it — the ripple
|
|
698
|
+
"""One principle, its enforcement state, and the decisions that rest on it — the ripple made visible."""
|
|
664
699
|
|
|
665
700
|
principle: Principle
|
|
666
701
|
declined: tuple[Need, ...] # needs withdrawn citing this principle
|
|
667
702
|
stale: tuple[Need, ...] # of those, decided before the principle's latest revision — re-review
|
|
703
|
+
state: PrincipleState = PrincipleState.STATED # enforced/stated/violated/broken (#89)
|
|
668
704
|
|
|
669
705
|
|
|
670
706
|
def _decided_before(decided: str, revised: str | None) -> bool:
|
|
@@ -681,13 +717,19 @@ def _decided_before(decided: str, revised: str | None) -> bool:
|
|
|
681
717
|
return False
|
|
682
718
|
|
|
683
719
|
|
|
684
|
-
def principle_report(
|
|
685
|
-
|
|
720
|
+
def principle_report(
|
|
721
|
+
needs: list[Need],
|
|
722
|
+
principles: list[Principle],
|
|
723
|
+
adapters: dict[str, Adapter] | None = None,
|
|
724
|
+
) -> list[PrincipleReport]:
|
|
725
|
+
"""Each declared principle with its enforcement state, the needs withdrawn under it, and the stale ones.
|
|
686
726
|
|
|
687
727
|
Reads the *whole* need list, withdrawn needs included — a declined need is off the board, so
|
|
688
728
|
the board-filtered list would hide exactly what this reports. A decision is stale when the
|
|
689
729
|
withdrawal is dated before the principle's ``revised_at``: the frame it was judged against has
|
|
690
730
|
changed since, so it comes back up for re-review. Records-only — prodc flags, the PM re-decides.
|
|
731
|
+
When ``adapters`` are given, each principle also carries its :func:`principle_state` (#89); without
|
|
732
|
+
them the state defaults to STATED (a caller that has no proof adapters, e.g. a plain ripple view).
|
|
691
733
|
"""
|
|
692
734
|
by_principle: dict[str, list[Need]] = {a.id: [] for a in principles}
|
|
693
735
|
for n in needs:
|
|
@@ -701,7 +743,8 @@ def principle_report(needs: list[Need], principles: list[Principle]) -> list[Pri
|
|
|
701
743
|
for n in declined
|
|
702
744
|
if n.withdrawn is not None and _decided_before(n.withdrawn.date, a.revised_at)
|
|
703
745
|
]
|
|
704
|
-
|
|
746
|
+
state = principle_state(a, adapters) if adapters is not None else PrincipleState.STATED
|
|
747
|
+
out.append(PrincipleReport(a, tuple(declined), tuple(stale), state))
|
|
705
748
|
return out
|
|
706
749
|
|
|
707
750
|
|
|
@@ -46,7 +46,7 @@ _SCALAR_FIELDS = frozenset(
|
|
|
46
46
|
"draft",
|
|
47
47
|
}
|
|
48
48
|
)
|
|
49
|
-
_LIST_FIELDS = frozenset({"proofs", "children", "contradicts"})
|
|
49
|
+
_LIST_FIELDS = frozenset({"proofs", "children", "contradicts", "tags"})
|
|
50
50
|
|
|
51
51
|
# Source-kind aliases accepted on write (old names → the primary/secondary/assumption ladder),
|
|
52
52
|
# normalised so new files use the current vocabulary even when an author types the old kind.
|
|
@@ -915,6 +915,8 @@ def add_principle(
|
|
|
915
915
|
file: str,
|
|
916
916
|
revised_at: str,
|
|
917
917
|
scope: str = "product",
|
|
918
|
+
implemented_in: str | None = None,
|
|
919
|
+
proofs: tuple[str, ...] = (),
|
|
918
920
|
sources: tuple[Source, ...] = (),
|
|
919
921
|
expect_sha: str | None = None,
|
|
920
922
|
dry_run: bool,
|
|
@@ -948,6 +950,10 @@ def add_principle(
|
|
|
948
950
|
if scope != "product":
|
|
949
951
|
entry["scope"] = scope # written only when non-default, to keep the common case clean
|
|
950
952
|
entry["revised_at"] = revised_at
|
|
953
|
+
if implemented_in is not None:
|
|
954
|
+
entry["implemented_in"] = implemented_in
|
|
955
|
+
if proofs:
|
|
956
|
+
entry["proofs"] = list(proofs)
|
|
951
957
|
if sources:
|
|
952
958
|
entry["sources"] = [_source_dict(s) for s in sources]
|
|
953
959
|
principles.append(entry)
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
10
|
from prodc.config import Config, ConfigError
|
|
11
|
-
from prodc.dedup import duplicate_pairs, matches_for, similarity
|
|
11
|
+
from prodc.dedup import duplicate_pairs, matches_for, similarity, tag_warnings
|
|
12
12
|
from prodc.model import Need
|
|
13
13
|
|
|
14
14
|
|
|
@@ -228,3 +228,20 @@ def test_score_prune_matches_brute_force() -> None:
|
|
|
228
228
|
if s >= threshold:
|
|
229
229
|
brute[frozenset((needs[i].id, needs[j].id))] = round(s, 10)
|
|
230
230
|
assert got == brute, f"mismatch at threshold {threshold}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_tag_warnings_singleton_nearspelling_and_structure() -> None:
|
|
234
|
+
needs = [
|
|
235
|
+
_n("N-1", "a", tags=("search", "searching", "onboarding")),
|
|
236
|
+
_n("N-2", "b", tags=("search", "onboarding")),
|
|
237
|
+
_n("N-3", "c", tags=("search", "G-1")), # G-1 is a declared goal id
|
|
238
|
+
]
|
|
239
|
+
warns = tag_warnings(needs, structural_ids={"G-1"}, threshold=0.4)
|
|
240
|
+
msgs = {(t, m) for t, m in warns}
|
|
241
|
+
tags_warned = {t for t, _ in warns}
|
|
242
|
+
# G-1 is structural, searching is a singleton, search is used 4x (no singleton warning)
|
|
243
|
+
assert any(t == "G-1" and "declared goal/role/release/client" in m for t, m in msgs)
|
|
244
|
+
assert any(t == "searching" and "singleton" in m for t, m in msgs)
|
|
245
|
+
assert any("near-duplicate spelling" in m for _, m in msgs)
|
|
246
|
+
assert "search" not in {t for t, m in msgs if "singleton" in m} # 4 uses, not a singleton
|
|
247
|
+
assert tags_warned # sanity
|
|
@@ -262,6 +262,16 @@ def test_goals_loaded_and_need_traces_up(tmp_path: Path) -> None:
|
|
|
262
262
|
assert model.needs[0].goal == "G-1"
|
|
263
263
|
|
|
264
264
|
|
|
265
|
+
def test_need_loads_tags(tmp_path: Path) -> None:
|
|
266
|
+
# #80: tags are a flat, many-to-many label list on a need.
|
|
267
|
+
cfg = _config(
|
|
268
|
+
tmp_path,
|
|
269
|
+
("a.yaml", "needs:\n - id: N-1\n text: a need\n tags: [search, onboarding]\n"),
|
|
270
|
+
)
|
|
271
|
+
(need,) = load_needs(cfg)
|
|
272
|
+
assert need.tags == ("search", "onboarding")
|
|
273
|
+
|
|
274
|
+
|
|
265
275
|
def test_goal_loads_description_and_target(tmp_path: Path) -> None:
|
|
266
276
|
# #80: a goal is the home for the initiative — a description and an optional target window.
|
|
267
277
|
from prodc.loader import load
|
|
@@ -7,15 +7,32 @@ it rests on; revising that principle later flags the decision for re-review (the
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
from datetime import date
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
import pytest
|
|
14
15
|
|
|
16
|
+
from prodc.adapters import Adapter, ProofResult
|
|
15
17
|
from prodc.config import Config
|
|
16
18
|
from prodc.loader import LoaderError, load
|
|
17
|
-
from prodc.model import Principle, Source, SourceKind
|
|
18
|
-
from prodc.status import principle_report
|
|
19
|
+
from prodc.model import Principle, PrincipleState, ProofState, Source, SourceKind
|
|
20
|
+
from prodc.status import principle_report, principle_state
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _Stub:
|
|
25
|
+
"""Resolves any payload to a fixed ProofResult — a per-adapter test double."""
|
|
26
|
+
|
|
27
|
+
result: ProofResult
|
|
28
|
+
|
|
29
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
30
|
+
_ = payload
|
|
31
|
+
return self.result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _adapter(state: ProofState, *, declared: bool) -> dict[str, Adapter]:
|
|
35
|
+
return {"g": _Stub(ProofResult(state, declared=declared, detail="stub"))}
|
|
19
36
|
|
|
20
37
|
_TOML = """
|
|
21
38
|
[prodc]
|
|
@@ -179,6 +196,64 @@ def test_add_principle_writes_scope(tmp_path: Path) -> None:
|
|
|
179
196
|
assert next(p for p in model.principles if p.id == "P-9").scope == "visual"
|
|
180
197
|
|
|
181
198
|
|
|
199
|
+
# --- enforced / stated / violated / broken (#89) ----------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _p(**kw: object) -> Principle:
|
|
203
|
+
return Principle(id="P-1", text="colour is load-bearing", **kw) # type: ignore[arg-type]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_no_proofs_is_stated() -> None:
|
|
207
|
+
# a principle with no guards is honestly STATED — allowed, never a gate failure.
|
|
208
|
+
assert principle_state(_p(), _adapter(ProofState.DELIVERED, declared=True)) is PrincipleState.STATED
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_all_proofs_delivered_is_enforced() -> None:
|
|
212
|
+
p = _p(proofs=("g:noPurple",))
|
|
213
|
+
assert principle_state(p, _adapter(ProofState.DELIVERED, declared=True)) is PrincipleState.ENFORCED
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_failing_guard_with_results_is_violated() -> None:
|
|
217
|
+
# the trap: a guard that ran and FAILED (results artifact present, not declared) means the code
|
|
218
|
+
# contradicts the principle NOW — VIOLATED, never a mere STATED/PENDING. "The board cannot lie."
|
|
219
|
+
p = _p(proofs=("g:noPurple",))
|
|
220
|
+
assert principle_state(p, _adapter(ProofState.PENDING, declared=False)) is PrincipleState.VIOLATED
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_declared_pending_guard_is_stated_not_violated() -> None:
|
|
224
|
+
# a guard that is only declared (e.g. @pending, no results) is not yet active — STATED, not VIOLATED.
|
|
225
|
+
p = _p(proofs=("g:noPurple",))
|
|
226
|
+
assert principle_state(p, _adapter(ProofState.PENDING, declared=True)) is PrincipleState.STATED
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_broken_proof_is_broken() -> None:
|
|
230
|
+
p = _p(proofs=("g:noPurple",))
|
|
231
|
+
assert principle_state(p, _adapter(ProofState.BROKEN, declared=False)) is PrincipleState.BROKEN
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_dangling_implemented_in_is_broken_even_without_proofs() -> None:
|
|
235
|
+
p = _p(implemented_in="g:tokens.css")
|
|
236
|
+
assert principle_state(p, _adapter(ProofState.BROKEN, declared=False)) is PrincipleState.BROKEN
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_need_proof_on_principle_rejected(tmp_path: Path) -> None:
|
|
240
|
+
needs = "principles:\n - id: P-1\n text: a principle\n proofs:\n - 'need:N-1@yes'\n"
|
|
241
|
+
with pytest.raises(LoaderError, match="guards the code, it does not wait on a need"):
|
|
242
|
+
load(Config.load(_project(tmp_path, needs=needs)))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_lint_hard_fails_broken_principle(tmp_path: Path) -> None:
|
|
246
|
+
import argparse
|
|
247
|
+
|
|
248
|
+
from prodc.cli.commands import cmd_lint
|
|
249
|
+
|
|
250
|
+
# a principle whose only proof is an unresolvable locator (no 'ghost' adapter) is BROKEN.
|
|
251
|
+
needs = "principles:\n - id: P-1\n text: a principle\n proofs:\n - 'ghost:x'\n"
|
|
252
|
+
cfg_path = _project(tmp_path, needs=needs)
|
|
253
|
+
args = argparse.Namespace(config=str(cfg_path), strict=False, json=False)
|
|
254
|
+
assert cmd_lint(args) == 1
|
|
255
|
+
|
|
256
|
+
|
|
182
257
|
# --- principle_report (declined / stale) ------------------------------------
|
|
183
258
|
|
|
184
259
|
|
|
@@ -302,6 +302,14 @@ def test_children_append_bare_id(tmp_path: Path) -> None:
|
|
|
302
302
|
assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", None)]
|
|
303
303
|
|
|
304
304
|
|
|
305
|
+
def test_tags_append(tmp_path: Path) -> None:
|
|
306
|
+
# #80: tags are a plain string list; `tags+=X` appends like proofs.
|
|
307
|
+
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: a need\n tags: [search]\n")
|
|
308
|
+
set_field(cfg, "N-1", "tags+=onboarding", expect_sha=None, dry_run=False)
|
|
309
|
+
need = next(n for n in load(cfg).needs if n.id == "N-1")
|
|
310
|
+
assert need.tags == ("search", "onboarding")
|
|
311
|
+
|
|
312
|
+
|
|
305
313
|
def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
|
|
306
314
|
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
|
|
307
315
|
with pytest.raises(WriteError, match="no need 'N-9'"):
|
|
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
|