prodc 0.4.0__tar.gz → 0.4.2__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.0 → prodc-0.4.2}/PKG-INFO +1 -1
- {prodc-0.4.0 → prodc-0.4.2}/pyproject.toml +1 -1
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/render.py +4 -1
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/serialize.py +1 -1
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/loader.py +54 -6
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/model.py +16 -1
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/status.py +6 -6
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/writer.py +222 -145
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_loader.py +41 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_models.py +2 -2
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_status.py +7 -7
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_writer.py +27 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_writer_comments.py +46 -1
- {prodc-0.4.0 → prodc-0.4.2}/.gitignore +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/CHANGELOG.md +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/LICENSE +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/README.md +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/__init__.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/adapters.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/agent.md +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/board.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/callbacks.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/app.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/commands.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/cli/common.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/config.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/dedup.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/guide.md +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/py.typed +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/report.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/src/prodc/report_html.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_adapters.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_answer.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_callbacks.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_citation.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_claim_gating.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_cli.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_command_hook.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_contradicts.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_decisions.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_dedup.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_description.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_drafts.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_evalgate.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_external_adapter.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_gitlab_source.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_importance.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_needref.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_principles.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_relink.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_report.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_report_html.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_review.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_roles.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_source_role.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_sources.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_staleness.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_suggest.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_variants.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_who_variants.py +0 -0
- {prodc-0.4.0 → prodc-0.4.2}/tests/test_withdrawn.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.2
|
|
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
|
|
@@ -382,7 +382,10 @@ def render_show(
|
|
|
382
382
|
else:
|
|
383
383
|
lines.append(" sources: (none — opinion)")
|
|
384
384
|
if need.children:
|
|
385
|
-
|
|
385
|
+
rendered = ", ".join(
|
|
386
|
+
c.id if c.step is None else f"{c.id}@{c.step}" for c in need.children
|
|
387
|
+
)
|
|
388
|
+
lines.append(f" children: {rendered}")
|
|
386
389
|
if need.contradicts:
|
|
387
390
|
lines.append(f" contradicts: {', '.join(need.contradicts)}")
|
|
388
391
|
if need.seam:
|
|
@@ -160,7 +160,7 @@ def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
|
|
|
160
160
|
for s in need.sources
|
|
161
161
|
],
|
|
162
162
|
"proofs": list(need.proofs),
|
|
163
|
-
"children":
|
|
163
|
+
"children": [{"id": c.id, "step": c.step} for c in need.children],
|
|
164
164
|
"seam": list(need.seam),
|
|
165
165
|
}
|
|
166
166
|
)
|
|
@@ -8,6 +8,7 @@ IDs are globally unique (children reference ids across files), so a duplicate is
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import datetime
|
|
11
|
+
import re
|
|
11
12
|
from collections.abc import Callable
|
|
12
13
|
from dataclasses import dataclass, field, replace
|
|
13
14
|
from pathlib import Path
|
|
@@ -20,6 +21,7 @@ from .model import (
|
|
|
20
21
|
Answer,
|
|
21
22
|
Attestation,
|
|
22
23
|
AttestationKind,
|
|
24
|
+
Child,
|
|
23
25
|
Decision,
|
|
24
26
|
Goal,
|
|
25
27
|
Model,
|
|
@@ -202,6 +204,21 @@ def _source(raw: object, where: str) -> Source:
|
|
|
202
204
|
)
|
|
203
205
|
|
|
204
206
|
|
|
207
|
+
def _child(raw: object, where: str) -> Child:
|
|
208
|
+
"""Parse one flow edge: a bare id string, or a ``{id, step}`` mapping (#90).
|
|
209
|
+
|
|
210
|
+
A bare ``US-050`` stays valid (``Child("US-050")`` with no step), so existing flows load
|
|
211
|
+
unchanged; a mapping adds the optional ``step`` the edge plays in the journey.
|
|
212
|
+
"""
|
|
213
|
+
if isinstance(raw, str):
|
|
214
|
+
return Child(id=raw)
|
|
215
|
+
tbl = _as_mapping(raw, where)
|
|
216
|
+
return Child(
|
|
217
|
+
id=_as_str(tbl.get("id"), f"{where}.id"),
|
|
218
|
+
step=_opt_str(tbl.get("step"), f"{where}.step"),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
205
222
|
def _need(
|
|
206
223
|
raw: object,
|
|
207
224
|
where: str,
|
|
@@ -242,7 +259,7 @@ def _need(
|
|
|
242
259
|
for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
|
|
243
260
|
)
|
|
244
261
|
children = tuple(
|
|
245
|
-
|
|
262
|
+
_child(c, f"{where}.children[{i}]")
|
|
246
263
|
for i, c in enumerate(_as_list(tbl.get("children"), f"{where}.children"))
|
|
247
264
|
)
|
|
248
265
|
seam = tuple(
|
|
@@ -383,13 +400,44 @@ class _Loaded:
|
|
|
383
400
|
decisions: list[Decision] = field(default_factory=list[Decision])
|
|
384
401
|
|
|
385
402
|
|
|
403
|
+
def yaml_error_message(where: str, text: str, exc: yaml.YAMLError) -> str:
|
|
404
|
+
"""Turn a raw PyYAML parse error into a prodc message naming the file, line and nearest need (#84).
|
|
405
|
+
|
|
406
|
+
PyYAML points at ``"<unicode string>", line N`` with no file or need id; on a 200-line board that
|
|
407
|
+
leaves the author hunting by hand. We name the file and line, scan back to the nearest ``id:`` so
|
|
408
|
+
the offending need is identified, and — for the common ``": "``-in-prose case — spell out the fix.
|
|
409
|
+
"""
|
|
410
|
+
mark = getattr(exc, "problem_mark", None)
|
|
411
|
+
problem = getattr(exc, "problem", None) or "invalid YAML"
|
|
412
|
+
if mark is None:
|
|
413
|
+
return f"{where}: invalid YAML: {exc}"
|
|
414
|
+
lines = text.splitlines()
|
|
415
|
+
loc = f"{where}: line {mark.line + 1}"
|
|
416
|
+
nid: str | None = None
|
|
417
|
+
for j in range(min(mark.line, len(lines) - 1), -1, -1):
|
|
418
|
+
m = re.match(r"^\s*-?\s*id:\s*(\S+)", lines[j])
|
|
419
|
+
if m:
|
|
420
|
+
nid = m.group(1).strip("\"'")
|
|
421
|
+
break
|
|
422
|
+
if nid is not None:
|
|
423
|
+
loc += f" (need {nid!r})"
|
|
424
|
+
msg = f"{loc}: {problem}"
|
|
425
|
+
if "mapping values are not allowed here" in problem:
|
|
426
|
+
msg += (
|
|
427
|
+
" — a ': ' in an unquoted claim/text reads as a nested mapping; "
|
|
428
|
+
"quote the value in single quotes or rephrase"
|
|
429
|
+
)
|
|
430
|
+
return msg
|
|
431
|
+
|
|
432
|
+
|
|
386
433
|
def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
|
|
387
434
|
"""Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``principles:``/…)."""
|
|
388
435
|
where = path.name
|
|
436
|
+
text = path.read_text(encoding="utf-8")
|
|
389
437
|
try:
|
|
390
|
-
doc = yaml.safe_load(
|
|
438
|
+
doc = yaml.safe_load(text)
|
|
391
439
|
except yaml.YAMLError as exc:
|
|
392
|
-
raise LoaderError(
|
|
440
|
+
raise LoaderError(yaml_error_message(where, text, exc)) from exc
|
|
393
441
|
out = _Loaded()
|
|
394
442
|
if doc is None:
|
|
395
443
|
return out
|
|
@@ -532,9 +580,9 @@ def _check_children(needs: list[Need]) -> None:
|
|
|
532
580
|
"""Every id in a need's ``children`` must name a known need."""
|
|
533
581
|
known = {n.id for n in needs}
|
|
534
582
|
for need in needs:
|
|
535
|
-
for
|
|
536
|
-
if
|
|
537
|
-
raise LoaderError(f"need {need.id!r}: child {
|
|
583
|
+
for c in need.children:
|
|
584
|
+
if c.id not in known:
|
|
585
|
+
raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
|
|
538
586
|
|
|
539
587
|
|
|
540
588
|
def _check_withdrawal_of(needs: list[Need]) -> None:
|
|
@@ -257,6 +257,21 @@ class Withdrawal:
|
|
|
257
257
|
of: str | None = None # id of the need this one duplicates (required when kind is duplicate)
|
|
258
258
|
|
|
259
259
|
|
|
260
|
+
@dataclass(frozen=True)
|
|
261
|
+
class Child:
|
|
262
|
+
"""One edge of a flow: the sub-need and, optionally, the ``step`` it plays in the journey (#90).
|
|
263
|
+
|
|
264
|
+
A flow's ``children`` are ordered, and a use case ("job") is just a flow with a ``role`` set and
|
|
265
|
+
a ``step`` on each edge (locate / execute / conclude). The step annotates the *edge*, not the
|
|
266
|
+
sub-need — the same need can be the ``execute`` step of one flow and the ``locate`` of another —
|
|
267
|
+
so it lives here, not on :class:`Need`. A bare ``US-050`` in YAML loads as ``Child("US-050")``
|
|
268
|
+
with no step, so every existing flow keeps working unchanged.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
id: str
|
|
272
|
+
step: str | None = None
|
|
273
|
+
|
|
274
|
+
|
|
260
275
|
@dataclass(frozen=True)
|
|
261
276
|
class Need:
|
|
262
277
|
"""What a named person wants, with its provenance and its proofs."""
|
|
@@ -269,7 +284,7 @@ class Need:
|
|
|
269
284
|
must: bool = False
|
|
270
285
|
sources: tuple[Source, ...] = ()
|
|
271
286
|
proofs: tuple[str, ...] = () # locators, "<adapter>:<payload>" (leaf needs)
|
|
272
|
-
children: tuple[
|
|
287
|
+
children: tuple[Child, ...] = () # ordered sub-need edges (a flow is a Need with children)
|
|
273
288
|
seam: tuple[str, ...] = () # end-to-end proof locators for a flow's between-steps behaviour
|
|
274
289
|
client: str | None = None # which client this instance is for; None = shared baseline
|
|
275
290
|
variant_of: str | None = None # id of the baseline need this specialises; None = standalone
|
|
@@ -293,11 +293,11 @@ class Resolver:
|
|
|
293
293
|
warnings: list[str] = []
|
|
294
294
|
child_states: list[NeedState] = []
|
|
295
295
|
declared = False
|
|
296
|
-
for
|
|
297
|
-
if
|
|
298
|
-
warnings.append(f"unknown child id '{
|
|
296
|
+
for c in need.children:
|
|
297
|
+
if c.id not in self._by_id:
|
|
298
|
+
warnings.append(f"unknown child id '{c.id}'")
|
|
299
299
|
continue
|
|
300
|
-
cr = self.resolve(
|
|
300
|
+
cr = self.resolve(c.id)
|
|
301
301
|
child_states.append(cr.state)
|
|
302
302
|
declared = declared or cr.declared
|
|
303
303
|
delivered = sum(1 for s in child_states if s is NeedState.DELIVERED)
|
|
@@ -806,7 +806,7 @@ def handoffs(needs: list[Need]) -> list[Handoff]:
|
|
|
806
806
|
for flow in needs:
|
|
807
807
|
if not flow.children:
|
|
808
808
|
continue
|
|
809
|
-
steps = [by_id[c] for c in flow.children if c in by_id]
|
|
809
|
+
steps = [by_id[c.id] for c in flow.children if c.id in by_id]
|
|
810
810
|
for a, b in pairwise(steps):
|
|
811
811
|
if a.role and b.role and a.role != b.role:
|
|
812
812
|
out.append(Handoff(flow, a, b, a.role, b.role))
|
|
@@ -844,7 +844,7 @@ def flow_orphan_warnings(needs: list[Need]) -> set[str]:
|
|
|
844
844
|
return set()
|
|
845
845
|
in_a_flow: set[str] = set()
|
|
846
846
|
for flow in flows:
|
|
847
|
-
in_a_flow.update(flow.children)
|
|
847
|
+
in_a_flow.update(c.id for c in flow.children)
|
|
848
848
|
return {n.id for n in needs if not n.children and n.id not in in_a_flow}
|
|
849
849
|
|
|
850
850
|
|
|
@@ -14,6 +14,7 @@ import datetime
|
|
|
14
14
|
import difflib
|
|
15
15
|
import hashlib
|
|
16
16
|
import re
|
|
17
|
+
from collections.abc import Callable
|
|
17
18
|
from dataclasses import dataclass
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from typing import TYPE_CHECKING, cast
|
|
@@ -24,7 +25,7 @@ from .config import Config
|
|
|
24
25
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from .model import Source
|
|
27
|
-
from .loader import LoaderError, load
|
|
28
|
+
from .loader import LoaderError, load, yaml_error_message
|
|
28
29
|
|
|
29
30
|
# Fields an agent may `set` on a need. Unknown keys are silently ignored by the loader, so the
|
|
30
31
|
# write path allowlists instead — a typo'd key is an error here, not junk written to disk.
|
|
@@ -106,23 +107,24 @@ def _need_block_span(lines: list[str], need_id: str) -> tuple[int, int, int] | N
|
|
|
106
107
|
return _block_span(lines, "needs", need_id)
|
|
107
108
|
|
|
108
109
|
|
|
109
|
-
def
|
|
110
|
-
"""
|
|
110
|
+
def _scan_list(lines: list[str], key: str) -> tuple[list[tuple[int, str | None]], int, int] | None:
|
|
111
|
+
"""Scan the top-level ``key:`` list; returns ``(item_starts, region_end, item_indent)`` or None.
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
``item_starts`` is ``(start_line, id)`` per list item, ``region_end`` is the line index just
|
|
114
|
+
past the list (before trailing blanks/comments are trimmed), and ``item_indent`` is the ``- ``
|
|
115
|
+
indent. Line-based and deliberately conservative: it understands the common prodc layout (a
|
|
113
116
|
top-level list key — ``needs:``, ``principles:`` — whose items are ``- `` at a fixed indent) and
|
|
114
|
-
returns None for anything it is not sure about, so the caller can fall back to a full
|
|
115
|
-
|
|
116
|
-
introduces the *next* item is never swallowed.
|
|
117
|
+
returns None for anything it is not sure about, so the caller can fall back to a full re-dump.
|
|
118
|
+
The single scanner behind both the edit splice (:func:`_block_span`) and the append (#85).
|
|
117
119
|
"""
|
|
118
120
|
key_line = re.compile(rf"^{re.escape(key)}:\s*(#.*)?$")
|
|
119
|
-
|
|
120
|
-
if
|
|
121
|
+
key_at = next((i for i, ln in enumerate(lines) if key_line.match(ln)), None)
|
|
122
|
+
if key_at is None:
|
|
121
123
|
return None
|
|
122
124
|
item_re = None
|
|
123
125
|
item_indent = -1
|
|
124
|
-
i =
|
|
125
|
-
|
|
126
|
+
i = key_at + 1
|
|
127
|
+
starts: list[tuple[int, str | None]] = []
|
|
126
128
|
while i < len(lines):
|
|
127
129
|
line = lines[i]
|
|
128
130
|
stripped = line.strip()
|
|
@@ -133,19 +135,33 @@ def _block_span(lines: list[str], key: str, item_id: str) -> tuple[int, int, int
|
|
|
133
135
|
m = re.match(r"^(\s*)-\s", line)
|
|
134
136
|
if item_re is None:
|
|
135
137
|
if m is None:
|
|
136
|
-
return None # first non-blank line under
|
|
138
|
+
return None # first non-blank line under the key isn't a list item — unknown shape
|
|
137
139
|
item_indent = len(m.group(1))
|
|
138
140
|
item_re = re.compile(rf"^\s{{{item_indent}}}-\s")
|
|
139
141
|
if indent < item_indent and m is None:
|
|
140
|
-
break # dedented past the list — end of
|
|
142
|
+
break # dedented past the list — end of the key's region
|
|
141
143
|
if item_re.match(line):
|
|
142
|
-
|
|
144
|
+
starts.append((i, _item_id(lines, i, item_indent)))
|
|
143
145
|
elif indent <= item_indent and m is None:
|
|
144
146
|
break
|
|
145
147
|
i += 1
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
return starts, i, item_indent
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _block_span(lines: list[str], key: str, item_id: str) -> tuple[int, int, int] | None:
|
|
152
|
+
"""Line span [start, end) and item indent of the top-level ``key:`` list item ``item_id``.
|
|
153
|
+
|
|
154
|
+
Trailing blank and comment lines are left out of the span, so a comment that introduces the
|
|
155
|
+
*next* item is never swallowed. None when the key is absent, the shape is unrecognised, or no
|
|
156
|
+
item carries ``item_id`` — the caller then falls back to a full re-dump.
|
|
157
|
+
"""
|
|
158
|
+
scanned = _scan_list(lines, key)
|
|
159
|
+
if scanned is None:
|
|
160
|
+
return None
|
|
161
|
+
starts, region_end, item_indent = scanned
|
|
162
|
+
# close each span at the start of the next item (or `region_end`, the end of the list region)
|
|
163
|
+
bounds = [s[0] for s in starts] + [region_end]
|
|
164
|
+
for idx, (start, iid) in enumerate(starts):
|
|
149
165
|
if iid != item_id:
|
|
150
166
|
continue
|
|
151
167
|
end = bounds[idx + 1]
|
|
@@ -153,7 +169,7 @@ def _block_span(lines: list[str], key: str, item_id: str) -> tuple[int, int, int
|
|
|
153
169
|
lines[end - 1].strip() == "" or lines[end - 1].strip().startswith("#")
|
|
154
170
|
):
|
|
155
171
|
end -= 1
|
|
156
|
-
return start, end,
|
|
172
|
+
return start, end, item_indent
|
|
157
173
|
return None
|
|
158
174
|
|
|
159
175
|
|
|
@@ -234,6 +250,48 @@ def _render_item_edit(old_text: str, doc: dict[str, object], key: str, item_id:
|
|
|
234
250
|
return "".join(lines[:start]) + block + "".join(lines[end:])
|
|
235
251
|
|
|
236
252
|
|
|
253
|
+
def _render_item_append(old_text: str, doc: dict[str, object], key: str, item_id: str) -> str:
|
|
254
|
+
"""Append the new ``key:`` list item ``item_id`` to ``old_text``, every comment intact (#85).
|
|
255
|
+
|
|
256
|
+
The insert mirror of :func:`_render_item_edit`: dump only the new item and splice it in after
|
|
257
|
+
the last existing item of the list, so the header, the other items, and their comments stay
|
|
258
|
+
byte-for-byte. Falls back to a full re-dump when the file has no recognised ``key:`` list to
|
|
259
|
+
append to (a brand-new file or key, where there are no in-list comments to lose); the reload
|
|
260
|
+
gate in :func:`_validated` guards correctness either way.
|
|
261
|
+
"""
|
|
262
|
+
lines = old_text.splitlines(keepends=True)
|
|
263
|
+
scanned = _scan_list(lines, key)
|
|
264
|
+
items = doc.get(key)
|
|
265
|
+
if scanned is None or not isinstance(items, list):
|
|
266
|
+
return _dump(doc)
|
|
267
|
+
starts, region_end, item_indent = scanned
|
|
268
|
+
if not starts: # the key exists but holds no recognised item — re-dump rather than guess indent
|
|
269
|
+
return _dump(doc)
|
|
270
|
+
item = next(
|
|
271
|
+
(
|
|
272
|
+
cast("dict[str, object]", n)
|
|
273
|
+
for n in cast("list[object]", items)
|
|
274
|
+
if isinstance(n, dict) and cast("dict[str, object]", n).get("id") == item_id
|
|
275
|
+
),
|
|
276
|
+
None,
|
|
277
|
+
)
|
|
278
|
+
if item is None:
|
|
279
|
+
return _dump(doc)
|
|
280
|
+
# insert after the last item's content, before any trailing blank/comment lines of the section
|
|
281
|
+
insert_at = region_end
|
|
282
|
+
first_start = starts[0][0]
|
|
283
|
+
while insert_at - 1 >= first_start and (
|
|
284
|
+
lines[insert_at - 1].strip() == "" or lines[insert_at - 1].strip().startswith("#")
|
|
285
|
+
):
|
|
286
|
+
insert_at -= 1
|
|
287
|
+
dumped = yaml.safe_dump([item], sort_keys=False, allow_unicode=True, default_flow_style=False)
|
|
288
|
+
pad = " " * item_indent
|
|
289
|
+
block = "".join(pad + ln if ln.strip() else ln for ln in dumped.splitlines(keepends=True))
|
|
290
|
+
if insert_at > 0 and not lines[insert_at - 1].endswith("\n"):
|
|
291
|
+
block = "\n" + block # the item before the insert point had no trailing newline
|
|
292
|
+
return "".join(lines[:insert_at]) + block + "".join(lines[insert_at:])
|
|
293
|
+
|
|
294
|
+
|
|
237
295
|
def _render_delete(old_text: str, doc: dict[str, object], need_id: str) -> str:
|
|
238
296
|
"""Re-render after removing a need, excising only its block to keep every other comment (#60).
|
|
239
297
|
|
|
@@ -255,7 +313,7 @@ def _read_doc(path: Path) -> tuple[dict[str, object], str]:
|
|
|
255
313
|
try:
|
|
256
314
|
doc = yaml.safe_load(text)
|
|
257
315
|
except yaml.YAMLError as exc:
|
|
258
|
-
raise WriteError(
|
|
316
|
+
raise WriteError(yaml_error_message(path.name, text, exc)) from exc
|
|
259
317
|
if doc is None:
|
|
260
318
|
doc = {}
|
|
261
319
|
if not isinstance(doc, dict):
|
|
@@ -270,6 +328,14 @@ def _need_list(doc: dict[str, object], path: Path) -> list[object]:
|
|
|
270
328
|
return cast("list[object]", raw)
|
|
271
329
|
|
|
272
330
|
|
|
331
|
+
def _proof_list(need: dict[str, object], path: Path) -> list[object]:
|
|
332
|
+
"""A fresh copy of a need's ``proofs`` list, erroring if the field is present but not a list."""
|
|
333
|
+
current = need.get("proofs", [])
|
|
334
|
+
if not isinstance(current, list):
|
|
335
|
+
raise WriteError(f"{path.name}: 'proofs' is not a list")
|
|
336
|
+
return list(cast("list[object]", current))
|
|
337
|
+
|
|
338
|
+
|
|
273
339
|
def _config_files(config: Config) -> list[Path]:
|
|
274
340
|
seen: set[Path] = set()
|
|
275
341
|
out: list[Path] = []
|
|
@@ -299,6 +365,19 @@ def _coerce(value: str) -> object:
|
|
|
299
365
|
return value
|
|
300
366
|
|
|
301
367
|
|
|
368
|
+
def _child_item(raw: str) -> object:
|
|
369
|
+
"""Parse a ``children+=`` value into a flow edge: ``id`` stays a bare string, ``id@step`` a map.
|
|
370
|
+
|
|
371
|
+
Mirrors the loader's dual shape (#90): ``children+=US-050`` appends the bare id (unstepped),
|
|
372
|
+
``children+=US-052@execute`` appends ``{id: US-052, step: execute}``. A bare id keeps the file's
|
|
373
|
+
existing plain-string style; the ``@`` suffix mirrors the ``need:A@yes`` proof-locator idiom.
|
|
374
|
+
"""
|
|
375
|
+
head, sep, step = raw.partition("@")
|
|
376
|
+
if not sep:
|
|
377
|
+
return raw
|
|
378
|
+
return {"id": head.strip(), "step": step.strip()}
|
|
379
|
+
|
|
380
|
+
|
|
302
381
|
def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> None:
|
|
303
382
|
if "+=" in assignment:
|
|
304
383
|
key, _, raw = assignment.partition("+=")
|
|
@@ -311,7 +390,7 @@ def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> N
|
|
|
311
390
|
if not isinstance(current, list):
|
|
312
391
|
raise WriteError(f"{path.name}: {key!r} is not a list")
|
|
313
392
|
items = cast("list[object]", current)
|
|
314
|
-
items.append(raw.strip())
|
|
393
|
+
items.append(_child_item(raw.strip()) if key == "children" else raw.strip())
|
|
315
394
|
need[key] = items
|
|
316
395
|
return
|
|
317
396
|
key, sep, raw = assignment.partition("=")
|
|
@@ -364,21 +443,46 @@ def _validated(config: Config, path: Path, old_text: str, new_text: str, dry_run
|
|
|
364
443
|
return Edit(path=path, old_text=old_text, new_text=new_text)
|
|
365
444
|
|
|
366
445
|
|
|
367
|
-
def
|
|
368
|
-
config: Config,
|
|
446
|
+
def _edit_one_need(
|
|
447
|
+
config: Config,
|
|
448
|
+
need_id: str,
|
|
449
|
+
*,
|
|
450
|
+
expect_sha: str | None,
|
|
451
|
+
dry_run: bool,
|
|
452
|
+
mutate: Callable[[dict[str, object], Path], None],
|
|
369
453
|
) -> Edit:
|
|
370
|
-
"""
|
|
454
|
+
"""Run ``mutate`` on one existing need's raw mapping, comment-safe and reload-gated (#82).
|
|
455
|
+
|
|
456
|
+
The shared skeleton of every single-need writer: locate the file → read it → check the
|
|
457
|
+
optimistic lock → find the need by id → apply ``mutate`` (the only part that differs) → splice
|
|
458
|
+
just that need's block back (#53) → re-load as the schema and revert on failure (:func:`_validated`).
|
|
459
|
+
``mutate`` receives the need's mapping and the file path (for field-level errors) and edits the
|
|
460
|
+
mapping in place; raising :class:`WriteError` from it aborts before anything is written.
|
|
461
|
+
"""
|
|
371
462
|
path, _ = _locate(config, need_id)
|
|
372
463
|
doc, old_text = _read_doc(path)
|
|
373
464
|
if expect_sha is not None and _sha256(old_text) != expect_sha:
|
|
374
465
|
raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
|
|
375
466
|
for raw in _need_list(doc, path):
|
|
376
467
|
if isinstance(raw, dict) and cast("dict[str, object]", raw).get("id") == need_id:
|
|
377
|
-
|
|
468
|
+
mutate(cast("dict[str, object]", raw), path)
|
|
378
469
|
break
|
|
379
470
|
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
380
471
|
|
|
381
472
|
|
|
473
|
+
def set_field(
|
|
474
|
+
config: Config, need_id: str, assignment: str, *, expect_sha: str | None, dry_run: bool
|
|
475
|
+
) -> Edit:
|
|
476
|
+
"""Set or list-append one field on an existing need, validated against the loader."""
|
|
477
|
+
return _edit_one_need(
|
|
478
|
+
config,
|
|
479
|
+
need_id,
|
|
480
|
+
expect_sha=expect_sha,
|
|
481
|
+
dry_run=dry_run,
|
|
482
|
+
mutate=lambda need, path: _apply_assignment(need, assignment, path),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
382
486
|
def set_importance(
|
|
383
487
|
config: Config, need_id: str, level: int, when: str, *, expect_sha: str | None, dry_run: bool
|
|
384
488
|
) -> Edit:
|
|
@@ -387,17 +491,14 @@ def set_importance(
|
|
|
387
491
|
The level is already resolved against the rubric by the caller; this just writes both fields
|
|
388
492
|
together so ranking never refines on a number nobody has revisited.
|
|
389
493
|
"""
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
need["importance_set_at"] = when
|
|
399
|
-
break
|
|
400
|
-
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
494
|
+
|
|
495
|
+
def _mutate(need: dict[str, object], _path: Path) -> None:
|
|
496
|
+
need["importance"] = level
|
|
497
|
+
need["importance_set_at"] = when
|
|
498
|
+
|
|
499
|
+
return _edit_one_need(
|
|
500
|
+
config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
|
|
501
|
+
)
|
|
401
502
|
|
|
402
503
|
|
|
403
504
|
def capture_source(
|
|
@@ -423,10 +524,6 @@ def capture_source(
|
|
|
423
524
|
next load) — whose signal it is, orthogonal to the evidence tier (#52 follow-up).
|
|
424
525
|
"""
|
|
425
526
|
kind = _canonical_kind(kind)
|
|
426
|
-
path, _ = _locate(config, need_id)
|
|
427
|
-
doc, old_text = _read_doc(path)
|
|
428
|
-
if expect_sha is not None and _sha256(old_text) != expect_sha:
|
|
429
|
-
raise WriteError(f"{path.name} changed since it was read (optimistic lock)")
|
|
430
527
|
source: dict[str, object] = {"kind": kind}
|
|
431
528
|
for key, value in (
|
|
432
529
|
("who", who),
|
|
@@ -438,17 +535,18 @@ def capture_source(
|
|
|
438
535
|
):
|
|
439
536
|
if value is not None:
|
|
440
537
|
source[key] = value
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
538
|
+
|
|
539
|
+
def _mutate(need: dict[str, object], path: Path) -> None:
|
|
540
|
+
current = need.get("sources", [])
|
|
541
|
+
if not isinstance(current, list):
|
|
542
|
+
raise WriteError(f"{path.name}: 'sources' is not a list")
|
|
543
|
+
sources = cast("list[object]", current)
|
|
544
|
+
sources.append(source)
|
|
545
|
+
need["sources"] = sources
|
|
546
|
+
|
|
547
|
+
return _edit_one_need(
|
|
548
|
+
config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
|
|
549
|
+
)
|
|
452
550
|
|
|
453
551
|
|
|
454
552
|
def attest(
|
|
@@ -475,15 +573,13 @@ def attest(
|
|
|
475
573
|
att["verdict"] = verdict
|
|
476
574
|
if comment is not None:
|
|
477
575
|
att["comment"] = comment
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
break
|
|
486
|
-
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
576
|
+
return _edit_one_need(
|
|
577
|
+
config,
|
|
578
|
+
need_id,
|
|
579
|
+
expect_sha=expect_sha,
|
|
580
|
+
dry_run=dry_run,
|
|
581
|
+
mutate=lambda need, _path: need.__setitem__("attestation", att),
|
|
582
|
+
)
|
|
487
583
|
|
|
488
584
|
|
|
489
585
|
def withdraw(
|
|
@@ -515,15 +611,13 @@ def withdraw(
|
|
|
515
611
|
mark["principle"] = principle
|
|
516
612
|
if of is not None:
|
|
517
613
|
mark["of"] = of
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
break
|
|
526
|
-
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
614
|
+
return _edit_one_need(
|
|
615
|
+
config,
|
|
616
|
+
need_id,
|
|
617
|
+
expect_sha=expect_sha,
|
|
618
|
+
dry_run=dry_run,
|
|
619
|
+
mutate=lambda need, _path: need.__setitem__("withdrawn", mark),
|
|
620
|
+
)
|
|
527
621
|
|
|
528
622
|
|
|
529
623
|
def reinstate(config: Config, need_id: str, *, expect_sha: str | None, dry_run: bool) -> Edit:
|
|
@@ -532,18 +626,15 @@ def reinstate(config: Config, need_id: str, *, expect_sha: str | None, dry_run:
|
|
|
532
626
|
Errors if the need is not currently withdrawn — reinstating a live need is a mistake worth
|
|
533
627
|
surfacing, not a silent no-op.
|
|
534
628
|
"""
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
del need["withdrawn"]
|
|
545
|
-
break
|
|
546
|
-
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
629
|
+
|
|
630
|
+
def _mutate(need: dict[str, object], _path: Path) -> None:
|
|
631
|
+
if "withdrawn" not in need:
|
|
632
|
+
raise WriteError(f"need {need_id!r} is not withdrawn")
|
|
633
|
+
del need["withdrawn"]
|
|
634
|
+
|
|
635
|
+
return _edit_one_need(
|
|
636
|
+
config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
|
|
637
|
+
)
|
|
547
638
|
|
|
548
639
|
|
|
549
640
|
def _locate_decision(config: Config, decision_id: str) -> tuple[Path, dict[str, object], str]:
|
|
@@ -644,24 +735,19 @@ def accept(
|
|
|
644
735
|
one-liner instead of a hand edit. Idempotent: re-accepting the same who/date is a no-op.
|
|
645
736
|
"""
|
|
646
737
|
locator = f"accept:{who}@{date}"
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
current = need.get("proofs", [])
|
|
655
|
-
if not isinstance(current, list):
|
|
656
|
-
raise WriteError(f"{path.name}: 'proofs' is not a list")
|
|
657
|
-
proofs = [p for p in cast("list[object]", current)]
|
|
658
|
-
if revoke:
|
|
659
|
-
proofs = [p for p in proofs if p != locator]
|
|
660
|
-
elif locator not in proofs:
|
|
738
|
+
|
|
739
|
+
def _mutate(need: dict[str, object], path: Path) -> None:
|
|
740
|
+
proofs = _proof_list(need, path)
|
|
741
|
+
if revoke:
|
|
742
|
+
need["proofs"] = [p for p in proofs if p != locator]
|
|
743
|
+
else:
|
|
744
|
+
if locator not in proofs:
|
|
661
745
|
proofs.append(locator)
|
|
662
746
|
need["proofs"] = proofs
|
|
663
|
-
|
|
664
|
-
return
|
|
747
|
+
|
|
748
|
+
return _edit_one_need(
|
|
749
|
+
config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
|
|
750
|
+
)
|
|
665
751
|
|
|
666
752
|
|
|
667
753
|
def add_proof(
|
|
@@ -673,22 +759,15 @@ def add_proof(
|
|
|
673
759
|
batch apply can call it per need and show each need's diff. Re-adding a locator already on the
|
|
674
760
|
need is a no-op, so applying the same suggestion twice does not duplicate it (#54).
|
|
675
761
|
"""
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
raise WriteError(f"{path.name}: 'proofs' is not a list")
|
|
686
|
-
proofs = list(cast("list[object]", current))
|
|
687
|
-
if locator not in proofs:
|
|
688
|
-
proofs.append(locator)
|
|
689
|
-
need["proofs"] = proofs
|
|
690
|
-
break
|
|
691
|
-
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
762
|
+
def _mutate(need: dict[str, object], path: Path) -> None:
|
|
763
|
+
proofs = _proof_list(need, path)
|
|
764
|
+
if locator not in proofs:
|
|
765
|
+
proofs.append(locator)
|
|
766
|
+
need["proofs"] = proofs
|
|
767
|
+
|
|
768
|
+
return _edit_one_need(
|
|
769
|
+
config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
|
|
770
|
+
)
|
|
692
771
|
|
|
693
772
|
|
|
694
773
|
def relink_proof(
|
|
@@ -708,27 +787,20 @@ def relink_proof(
|
|
|
708
787
|
old locator is not actually on the need. One reload-gated single-file splice, like
|
|
709
788
|
:func:`add_proof`.
|
|
710
789
|
"""
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
deduped: list[object] = []
|
|
726
|
-
for p in swapped:
|
|
727
|
-
if p not in deduped:
|
|
728
|
-
deduped.append(p)
|
|
729
|
-
need["proofs"] = deduped
|
|
730
|
-
break
|
|
731
|
-
return _validated(config, path, old_text, _render_edit(old_text, doc, need_id), dry_run)
|
|
790
|
+
def _mutate(need: dict[str, object], path: Path) -> None:
|
|
791
|
+
proofs = _proof_list(need, path)
|
|
792
|
+
if old_locator not in proofs:
|
|
793
|
+
raise WriteError(f"{old_locator!r} is not a proof on {need_id!r}")
|
|
794
|
+
swapped = [new_locator if p == old_locator else p for p in proofs]
|
|
795
|
+
deduped: list[object] = []
|
|
796
|
+
for p in swapped:
|
|
797
|
+
if p not in deduped:
|
|
798
|
+
deduped.append(p)
|
|
799
|
+
need["proofs"] = deduped
|
|
800
|
+
|
|
801
|
+
return _edit_one_need(
|
|
802
|
+
config, need_id, expect_sha=expect_sha, dry_run=dry_run, mutate=_mutate
|
|
803
|
+
)
|
|
732
804
|
|
|
733
805
|
|
|
734
806
|
def add_need(
|
|
@@ -748,7 +820,8 @@ def add_need(
|
|
|
748
820
|
An agent-authored need must carry a source — and never ``primary``, which only a real user
|
|
749
821
|
signal earns. ``kind`` is restricted to ``assumption`` (the agent's own guess) or ``secondary``
|
|
750
822
|
(citing an existing document). A ``draft`` need is staged: it loads but is kept off the counted
|
|
751
|
-
board until promoted (#60).
|
|
823
|
+
board until promoted (#60). The need is appended with the comment-preserving splice (#85), so
|
|
824
|
+
existing needs and their comments stay byte-for-byte; a brand-new file is dumped fresh.
|
|
752
825
|
"""
|
|
753
826
|
kind = _SOURCE_KIND_ALIASES.get(kind, kind)
|
|
754
827
|
if kind not in ("assumption", "secondary"):
|
|
@@ -778,7 +851,8 @@ def add_need(
|
|
|
778
851
|
new_need["draft"] = True
|
|
779
852
|
new_need["draft_added"] = today # stamped so a lint age-gate can fire later (#60)
|
|
780
853
|
needs.append(new_need)
|
|
781
|
-
|
|
854
|
+
new_text = _render_item_append(old_text, doc, "needs", need_id)
|
|
855
|
+
return _validated(config, target, old_text, new_text, dry_run)
|
|
782
856
|
|
|
783
857
|
|
|
784
858
|
def add_role(
|
|
@@ -793,9 +867,9 @@ def add_role(
|
|
|
793
867
|
"""Declare a new Role in ``file``'s top-level ``roles:`` list (#52 follow-up).
|
|
794
868
|
|
|
795
869
|
The dedicated write-back for a role, so a roster entry is a one-liner instead of a hand
|
|
796
|
-
edit. Like :func:`add_need`,
|
|
797
|
-
|
|
798
|
-
still rejects anything that does not load. A duplicate role id is refused.
|
|
870
|
+
edit. Like :func:`add_need`, the new role is appended with the comment-preserving splice (#85),
|
|
871
|
+
so an existing file's comments stay byte-for-byte and only a brand-new file is dumped fresh;
|
|
872
|
+
the reload gate still rejects anything that does not load. A duplicate role id is refused.
|
|
799
873
|
"""
|
|
800
874
|
target = config.root / file
|
|
801
875
|
if target.exists():
|
|
@@ -813,7 +887,8 @@ def add_role(
|
|
|
813
887
|
):
|
|
814
888
|
raise WriteError(f"role {role_id!r} already exists in {target.name}")
|
|
815
889
|
roles.append({"id": role_id, "text": text})
|
|
816
|
-
|
|
890
|
+
new_text = _render_item_append(old_text, doc, "roles", role_id)
|
|
891
|
+
return _validated(config, target, old_text, new_text, dry_run)
|
|
817
892
|
|
|
818
893
|
|
|
819
894
|
def _source_dict(s: Source) -> dict[str, object]:
|
|
@@ -845,9 +920,9 @@ def add_principle(
|
|
|
845
920
|
) -> Edit:
|
|
846
921
|
"""Declare a new Principle in ``file``'s top-level ``principles:`` list, stamped ``revised_at``.
|
|
847
922
|
|
|
848
|
-
Mirrors :func:`add_role`:
|
|
849
|
-
anything that does not load (including an id already declared in another
|
|
850
|
-
duplicate id in this file is refused. ``revised_at`` is stamped at creation so the first
|
|
923
|
+
Mirrors :func:`add_role`: the new principle is appended with the comment-preserving splice (#85),
|
|
924
|
+
the reload gate rejects anything that does not load (including an id already declared in another
|
|
925
|
+
file), and a duplicate id in this file is refused. ``revised_at`` is stamped at creation so the first
|
|
851
926
|
revision has something to ripple against. ``sources`` carry the provenance that surfaced the
|
|
852
927
|
principle — the signals (often a client) a ``--from-need`` promotion copies across, so the
|
|
853
928
|
discovery history stays honest: the principle was discovered, not decreed.
|
|
@@ -872,7 +947,8 @@ def add_principle(
|
|
|
872
947
|
if sources:
|
|
873
948
|
entry["sources"] = [_source_dict(s) for s in sources]
|
|
874
949
|
principles.append(entry)
|
|
875
|
-
|
|
950
|
+
new_text = _render_item_append(old_text, doc, "principles", principle_id)
|
|
951
|
+
return _validated(config, target, old_text, new_text, dry_run)
|
|
876
952
|
|
|
877
953
|
|
|
878
954
|
def _locate_principle(config: Config, principle_id: str) -> tuple[Path, dict[str, object], str]:
|
|
@@ -934,7 +1010,7 @@ def add_decision(
|
|
|
934
1010
|
) -> Edit:
|
|
935
1011
|
"""Declare a new Decision (the solution chosen for ``needs``) in ``file``'s ``decisions:`` list.
|
|
936
1012
|
|
|
937
|
-
Mirrors :func:`add_principle` (
|
|
1013
|
+
Mirrors :func:`add_principle` (comment-preserving append #85, reload-gated, duplicate id refused). ``needs``
|
|
938
1014
|
is required — the loader rejects a decision that serves none, and the reload gate rejects an id
|
|
939
1015
|
that is not a known need. Empty optional lists are omitted so the written block stays clean.
|
|
940
1016
|
"""
|
|
@@ -965,7 +1041,8 @@ def add_decision(
|
|
|
965
1041
|
if draft:
|
|
966
1042
|
entry["draft"] = True
|
|
967
1043
|
decisions.append(entry)
|
|
968
|
-
|
|
1044
|
+
new_text = _render_item_append(old_text, doc, "decisions", decision_id)
|
|
1045
|
+
return _validated(config, target, old_text, new_text, dry_run)
|
|
969
1046
|
|
|
970
1047
|
|
|
971
1048
|
def _snapshot(paths: list[Path]) -> dict[Path, str | None]:
|
|
@@ -109,6 +109,26 @@ def test_flow_with_proofs_raises(tmp_path: Path) -> None:
|
|
|
109
109
|
load_needs(cfg)
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def test_children_load_as_bare_id_or_stepped_mapping(tmp_path: Path) -> None:
|
|
113
|
+
# #90: a flow's children take either a bare id (unstepped) or a {id, step} mapping, mixed.
|
|
114
|
+
cfg = _config(
|
|
115
|
+
tmp_path,
|
|
116
|
+
(
|
|
117
|
+
"a.yaml",
|
|
118
|
+
"needs:\n"
|
|
119
|
+
" - id: F-1\n"
|
|
120
|
+
" text: a flow\n"
|
|
121
|
+
" children:\n"
|
|
122
|
+
" - C-1\n"
|
|
123
|
+
" - {id: C-2, step: execute}\n"
|
|
124
|
+
" - id: C-1\n text: one\n"
|
|
125
|
+
" - id: C-2\n text: two\n",
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
flow = next(n for n in load_needs(cfg) if n.id == "F-1")
|
|
129
|
+
assert [(c.id, c.step) for c in flow.children] == [("C-1", None), ("C-2", "execute")]
|
|
130
|
+
|
|
131
|
+
|
|
112
132
|
def test_variant_scope_shared_baseline_across_clients(tmp_path: Path) -> None:
|
|
113
133
|
cfg = _config(
|
|
114
134
|
tmp_path,
|
|
@@ -366,3 +386,24 @@ def test_seam_only_on_flows(tmp_path: Path) -> None:
|
|
|
366
386
|
cfg = _config(tmp_path, ("a.yaml", "needs:\n - id: N-1\n text: t\n seam: [web:x#y]\n"))
|
|
367
387
|
with pytest.raises(LoaderError, match="'seam' is for a flow"):
|
|
368
388
|
load_needs(cfg)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_unquoted_colon_in_claim_names_the_need_and_the_fix(tmp_path: Path) -> None:
|
|
392
|
+
# #84: a ': ' in an unquoted prose claim must give a field-level error, not a raw YAML trace.
|
|
393
|
+
cfg = _config(
|
|
394
|
+
tmp_path,
|
|
395
|
+
(
|
|
396
|
+
"a.yaml",
|
|
397
|
+
"needs:\n"
|
|
398
|
+
" - id: N-5\n"
|
|
399
|
+
" text: original\n"
|
|
400
|
+
" claim: clear BOTH conditions with margin: (1) a and (2) b\n",
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
with pytest.raises(LoaderError) as exc:
|
|
404
|
+
load_needs(cfg)
|
|
405
|
+
msg = str(exc.value)
|
|
406
|
+
assert "a.yaml" in msg
|
|
407
|
+
assert "N-5" in msg # the offending need, not just a line/column
|
|
408
|
+
assert "line 4" in msg
|
|
409
|
+
assert "quote the value" in msg # the actionable hint
|
|
@@ -14,7 +14,7 @@ from dataclasses import dataclass, replace
|
|
|
14
14
|
from musil import Action, Model, check, explore, reaches
|
|
15
15
|
|
|
16
16
|
from prodc.adapters import Adapter, ProofResult
|
|
17
|
-
from prodc.model import Decision, Need, NeedState, ProofState, Withdrawal, board_needs
|
|
17
|
+
from prodc.model import Child, Decision, Need, NeedState, ProofState, Withdrawal, board_needs
|
|
18
18
|
from prodc.status import Resolver, active_decisions
|
|
19
19
|
|
|
20
20
|
|
|
@@ -141,7 +141,7 @@ def flow_state(f: Flow) -> NeedState:
|
|
|
141
141
|
"""The NeedState prodc's real aggregate gives this set of children + seam."""
|
|
142
142
|
children = [_child_need(f"c{i}", s) for i, s in enumerate(f.children)]
|
|
143
143
|
seam = tuple(f"stub:{s}" for s in f.seam if s != "none")
|
|
144
|
-
flow = Need(id="flow", text="f", children=tuple(c.id for c in children), seam=seam)
|
|
144
|
+
flow = Need(id="flow", text="f", children=tuple(Child(c.id) for c in children), seam=seam)
|
|
145
145
|
return Resolver([flow, *children], _ADAPTERS).resolve("flow").state
|
|
146
146
|
|
|
147
147
|
|
|
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
|
|
9
|
-
from prodc.model import Need, NeedState, ProofState, Source, SourceKind
|
|
9
|
+
from prodc.model import Child, Need, NeedState, ProofState, Source, SourceKind
|
|
10
10
|
from prodc.status import resolve
|
|
11
11
|
|
|
12
12
|
|
|
@@ -52,7 +52,7 @@ def test_flow_aggregates_two_of_three() -> None:
|
|
|
52
52
|
Need(id="C-2", text="two", proofs=("web:2",)),
|
|
53
53
|
Need(id="C-3", text="three", proofs=()), # a HOLE
|
|
54
54
|
)
|
|
55
|
-
flow = Need(id="F-1", text="a flow", children=("C-1", "C-2", "C-3"))
|
|
55
|
+
flow = Need(id="F-1", text="a flow", children=(Child("C-1"), Child("C-2"), Child("C-3")))
|
|
56
56
|
results = {r.need.id: r for r in resolve([flow, *children], {"web": _delivered()})}
|
|
57
57
|
flow_result = results["F-1"]
|
|
58
58
|
assert flow_result.state is NeedState.PENDING # not all delivered → pending
|
|
@@ -234,7 +234,7 @@ def test_coverage_counts_leaves_not_flow_parents() -> None:
|
|
|
234
234
|
from prodc.status import summarize
|
|
235
235
|
|
|
236
236
|
needs = [
|
|
237
|
-
Need(id="F-1", text="roast day flow", children=("L-1", "L-2")),
|
|
237
|
+
Need(id="F-1", text="roast day flow", children=(Child("L-1"), Child("L-2"))),
|
|
238
238
|
Need(id="L-1", text="step one", proofs=("x:1",)),
|
|
239
239
|
Need(id="L-2", text="step two", proofs=("x:1",)),
|
|
240
240
|
]
|
|
@@ -296,7 +296,7 @@ def test_flow_orphan_warning() -> None:
|
|
|
296
296
|
from prodc.status import flow_orphan_warnings
|
|
297
297
|
|
|
298
298
|
needs = [
|
|
299
|
-
Need(id="F-1", text="a flow", children=("L-1",)),
|
|
299
|
+
Need(id="F-1", text="a flow", children=(Child("L-1"),)),
|
|
300
300
|
Need(id="L-1", text="in the flow"),
|
|
301
301
|
Need(id="L-2", text="in no flow"),
|
|
302
302
|
]
|
|
@@ -354,7 +354,7 @@ def test_flow_seam_proof_gates_delivered() -> None:
|
|
|
354
354
|
# all children delivered but the seam proof pending → flow is PENDING, not DELIVERED (#44)
|
|
355
355
|
from prodc.adapters import Adapter
|
|
356
356
|
|
|
357
|
-
flow = Need(id="F-1", text="roast day", children=("L-1", "L-2"), seam=("web:seam#E2E",))
|
|
357
|
+
flow = Need(id="F-1", text="roast day", children=(Child("L-1"), Child("L-2")), seam=("web:seam#E2E",))
|
|
358
358
|
children = [
|
|
359
359
|
Need(id="L-1", text="a", proofs=("x:1",)),
|
|
360
360
|
Need(id="L-2", text="b", proofs=("x:1",)),
|
|
@@ -368,14 +368,14 @@ def test_flow_seam_proof_gates_delivered() -> None:
|
|
|
368
368
|
|
|
369
369
|
|
|
370
370
|
def test_flow_seam_green_delivers() -> None:
|
|
371
|
-
flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("web:seam#E2E",))
|
|
371
|
+
flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("web:seam#E2E",))
|
|
372
372
|
child = Need(id="L-1", text="a", proofs=("x:1",))
|
|
373
373
|
by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered(), "web": _delivered()})}
|
|
374
374
|
assert by_id["F-1"].state is NeedState.DELIVERED
|
|
375
375
|
|
|
376
376
|
|
|
377
377
|
def test_flow_seam_broken_breaks_flow() -> None:
|
|
378
|
-
flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("nope:x",))
|
|
378
|
+
flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("nope:x",))
|
|
379
379
|
child = Need(id="L-1", text="a", proofs=("x:1",))
|
|
380
380
|
by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered()})}
|
|
381
381
|
assert by_id["F-1"].state is NeedState.BROKEN # no 'nope' adapter → seam BROKEN
|
|
@@ -275,6 +275,33 @@ def test_delete_need_removes_the_block_keeping_others(tmp_path: Path) -> None:
|
|
|
275
275
|
assert "# keep me" in text # header comment survives the splice
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
def test_children_append_stepped_edge(tmp_path: Path) -> None:
|
|
279
|
+
# #90: `children+=id@step` appends a {id, step} edge; a bare id stays a plain string.
|
|
280
|
+
body = (
|
|
281
|
+
"needs:\n"
|
|
282
|
+
" - id: F-1\n text: a flow\n children: [N-2]\n"
|
|
283
|
+
" - id: N-2\n text: step two\n proofs: ['x:1']\n"
|
|
284
|
+
" - id: N-3\n text: step three\n proofs: ['x:2']\n"
|
|
285
|
+
)
|
|
286
|
+
cfg = _proj(tmp_path, body)
|
|
287
|
+
set_field(cfg, "F-1", "children+=N-3@conclude", expect_sha=None, dry_run=False)
|
|
288
|
+
flow = next(n for n in load(cfg).needs if n.id == "F-1")
|
|
289
|
+
assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", "conclude")]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_children_append_bare_id(tmp_path: Path) -> None:
|
|
293
|
+
body = (
|
|
294
|
+
"needs:\n"
|
|
295
|
+
" - id: F-1\n text: a flow\n children: [N-2]\n"
|
|
296
|
+
" - id: N-2\n text: step two\n proofs: ['x:1']\n"
|
|
297
|
+
" - id: N-3\n text: step three\n proofs: ['x:2']\n"
|
|
298
|
+
)
|
|
299
|
+
cfg = _proj(tmp_path, body)
|
|
300
|
+
set_field(cfg, "F-1", "children+=N-3", expect_sha=None, dry_run=False)
|
|
301
|
+
flow = next(n for n in load(cfg).needs if n.id == "F-1")
|
|
302
|
+
assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", None)]
|
|
303
|
+
|
|
304
|
+
|
|
278
305
|
def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
|
|
279
306
|
cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
|
|
280
307
|
with pytest.raises(WriteError, match="no need 'N-9'"):
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from prodc.config import Config
|
|
8
|
-
from prodc.writer import accept, capture_source, set_field
|
|
8
|
+
from prodc.writer import accept, add_need, capture_source, set_field
|
|
9
9
|
|
|
10
10
|
_TOML = """
|
|
11
11
|
[prodc]
|
|
@@ -111,3 +111,48 @@ def test_inline_list_file_falls_back_but_still_edits(tmp_path: Path) -> None:
|
|
|
111
111
|
set_field(cfg, "N-1", "text=edited via fallback", expect_sha=None, dry_run=False)
|
|
112
112
|
out = _read(tmp_path)
|
|
113
113
|
assert "edited via fallback" in out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_add_appends_and_preserves_comments(tmp_path: Path) -> None:
|
|
117
|
+
# #85: `add` must splice the new need in, not re-dump the whole file and drop every comment.
|
|
118
|
+
cfg = _project(tmp_path)
|
|
119
|
+
add_need(
|
|
120
|
+
cfg,
|
|
121
|
+
"N-3",
|
|
122
|
+
"a freshly added need",
|
|
123
|
+
who="agent",
|
|
124
|
+
kind="assumption",
|
|
125
|
+
file="needs/a.yaml",
|
|
126
|
+
expect_sha=None,
|
|
127
|
+
dry_run=False,
|
|
128
|
+
)
|
|
129
|
+
out = _read(tmp_path)
|
|
130
|
+
# every authored comment survives
|
|
131
|
+
assert "# Provenance contract: interview quotes are VERBATIM" in out
|
|
132
|
+
assert "# ids are minted in the N-16xx range." in out
|
|
133
|
+
assert "# this comment introduces the second need" in out
|
|
134
|
+
# the existing needs are untouched and the 2-space item indent is matched, not reflowed
|
|
135
|
+
assert " - id: N-1\n" in out
|
|
136
|
+
assert " text: second need stays untouched" in out
|
|
137
|
+
assert " - id: N-3\n" in out
|
|
138
|
+
assert "a freshly added need" in out
|
|
139
|
+
# the new need lands after N-2, not before it
|
|
140
|
+
assert out.index("id: N-3") > out.index("id: N-2")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_add_into_new_file_dumps_fresh(tmp_path: Path) -> None:
|
|
144
|
+
# No existing list to append to: fall back to a whole-file dump, which must still load.
|
|
145
|
+
cfg = _project(tmp_path)
|
|
146
|
+
add_need(
|
|
147
|
+
cfg,
|
|
148
|
+
"N-9",
|
|
149
|
+
"a need in a brand-new file",
|
|
150
|
+
who="agent",
|
|
151
|
+
kind="assumption",
|
|
152
|
+
file="needs/b.yaml",
|
|
153
|
+
expect_sha=None,
|
|
154
|
+
dry_run=False,
|
|
155
|
+
)
|
|
156
|
+
out = (tmp_path / "needs" / "b.yaml").read_text(encoding="utf-8")
|
|
157
|
+
assert "id: N-9" in out
|
|
158
|
+
assert "a need in a brand-new file" in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|