reqctl 0.1.38__py3-none-any.whl

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.
reqctl/write.py ADDED
@@ -0,0 +1,2004 @@
1
+ import math
2
+ import random
3
+ import re
4
+ import unicodedata
5
+
6
+ from . import baseline as _baseline
7
+ from . import corpus
8
+ from . import fields as _fields
9
+ from . import validate as _validate
10
+ from .corpus import ReqctlError
11
+
12
+ RELATIONS = ("derives_from", "depends_on", "constrains", "supersedes",
13
+ "conflicts_with")
14
+ STATEMENTS = frozenset(corpus.PREFIXES[prefix]
15
+ for prefix in _validate.SUBJECTS)
16
+ OPERATIONS = ("ack", "handle", "kind", "entry", "new_entry", "set", "append",
17
+ "unset", "drop_entry", "reword")
18
+ # @req> REQ-40281745@Ifnq5GBzdwaj vbv3ir
19
+ FORMS = {"unclaimed": "phrase=reason"}
20
+
21
+ KINDS = {kind: prefix.rstrip("-")
22
+ for prefix, kind in corpus.PREFIXES.items()}
23
+
24
+ MINT_ATTEMPTS = 10
25
+
26
+ GIVEN_UID = re.compile(rf"\A({corpus.KINDS})-[0-9]{{8}}\Z")
27
+
28
+ SEPARATED = re.compile(r"\d,\d")
29
+ BETWEEN_DIGITS = re.compile(r"\d\s*(\D)\s*\d")
30
+
31
+ ARTICLED_LINK = re.compile(r"(?:\b([Aa]n?)(\s+))?" + corpus.CONCEPT_LINK.pattern)
32
+
33
+
34
+ def _quoting(char):
35
+ return bool({"QUOTATION", "APOSTROPHE"} & set(unicodedata.name(char, "").split()))
36
+
37
+
38
+ def _listing(text):
39
+ return next((c for c in text
40
+ if unicodedata.category(c) == "Po"
41
+ and "COMMA" in unicodedata.name(c, "").split()), None)
42
+
43
+
44
+ def scalar(text, carried=frozenset()):
45
+ stripped = text.strip()
46
+ if stripped.startswith("[") and stripped.endswith("]"):
47
+ # @req> REQ-37302987@mgBn704avyaM 66xcug
48
+ if SEPARATED.search(stripped):
49
+ # @req> REQ-30898926@Vu28eWaG_1mE fbreuh
50
+ raise ReqctlError(
51
+ f"{stripped!r}: a comma between digits is ambiguous -- write a "
52
+ "number without digit separators (10000), and space set members "
53
+ "apart ([10, 20])"
54
+ )
55
+ inner = stripped[1:-1]
56
+ members = []
57
+ for part in inner.split(",") if inner.strip() else []:
58
+ # @req> REQ-79956352@OsoTGRd6D5CZ gdh2ib
59
+ _refuse_hidden(f"{part.strip()!r}:", part.strip(" "), carried)
60
+ members.append(part.strip())
61
+ for member in members:
62
+ if not member:
63
+ # @req> REQ-30898926@Vu28eWaG_1mE skicnd
64
+ raise ReqctlError(
65
+ f"{text!r}: a set member is empty, as a doubled or trailing "
66
+ "comma leaves nothing between the separators -- remove the "
67
+ "extra comma"
68
+ )
69
+ if "[" in member or "]" in member:
70
+ # @req> REQ-30898926@Vu28eWaG_1mE zqqx5c
71
+ raise ReqctlError(
72
+ f"{text!r}: a set is flat -- write its members inside one "
73
+ "pair of brackets, as in [json, markdown]"
74
+ )
75
+ if member and (_quoting(member[0]) or _quoting(member[-1])):
76
+ # @req> REQ-30898926@Vu28eWaG_1mE esggiu
77
+ raise ReqctlError(
78
+ f"{member!r}: a set member is unquoted, as the quotes would "
79
+ "be stored as part of the value -- drop the quotes"
80
+ )
81
+ grouped = {found.start(1) for found in BETWEEN_DIGITS.finditer(member)}
82
+ separator = next((c for i, c in enumerate(member)
83
+ if i not in grouped and _listing(c)), None)
84
+ if separator is not None:
85
+ # @req> REQ-30898926@Vu28eWaG_1mE 6yewzg
86
+ raise ReqctlError(
87
+ f"{member!r}: holds U+{ord(separator):04X}, a comma the set "
88
+ "is not split on -- separate members with an ordinary comma"
89
+ )
90
+ # @req> REQ-64256114@T4kJ-fI_AXrq wfslro
91
+ if inner and len(members) == 1:
92
+ # @req> REQ-30898926@Vu28eWaG_1mE ottxok
93
+ raise ReqctlError(
94
+ f"{stripped!r}: a set separates members with a comma -- "
95
+ "separate them as in [json, markdown], or drop the brackets "
96
+ "to state one value"
97
+ )
98
+ return [scalar(member, carried) for member in members]
99
+ # @req+ REQ-37302987@mgBn704avyaM t3a65b
100
+ separator = next((c for c in BETWEEN_DIGITS.findall(stripped)
101
+ if unicodedata.category(c) == "Zs"
102
+ or {"COMMA", "SEPARATOR", "APOSTROPHE", "QUOTATION"}
103
+ & set(unicodedata.name(c, "").split())), None)
104
+ if separator:
105
+ # @req> REQ-30898926@Vu28eWaG_1mE agkisl
106
+ raise ReqctlError(
107
+ f"{stripped!r}: U+{ord(separator):04X} between digits is a digit "
108
+ "separator, and which digits it groups is ambiguous -- write the "
109
+ "number whole (10000)"
110
+ )
111
+ # @req- t3a65b
112
+ # @req> REQ-79956352@OsoTGRd6D5CZ 6dcbid
113
+ _refuse_hidden(f"{stripped!r}:", text, carried)
114
+ if stripped.lower() in ("true", "false"):
115
+ return stripped.lower() == "true"
116
+ digits = stripped.lstrip("+-")
117
+ if digits.isdigit() and digits.startswith("0") and digits != "0":
118
+ return stripped
119
+ try:
120
+ return int(stripped)
121
+ except ValueError:
122
+ pass
123
+ try:
124
+ number = float(stripped)
125
+ except ValueError:
126
+ return stripped
127
+ return number if math.isfinite(number) else stripped
128
+
129
+
130
+ # @req> REQ-79956352@OsoTGRd6D5CZ 7g4u3w
131
+ def _refuse_hidden(label, value, carried=frozenset()):
132
+ hidden = _validate.hidden(str(value))
133
+ if hidden is not None and str(value).strip(" ") not in carried:
134
+ # @req> REQ-30898926@Vu28eWaG_1mE asmpgw
135
+ raise ReqctlError(f"{label} {_validate.hidden_fault(hidden)}")
136
+
137
+
138
+ def _restated(field, text, carried):
139
+ pieces = (text.split("|") if field.parts
140
+ else text.partition("=")[::2] if field.name == "unclaimed" else [text])
141
+ return all(piece.strip(" ") in carried for piece in pieces
142
+ if _validate.hidden(piece) is not None)
143
+
144
+
145
+ def _blank_or_hidden(fields, own, kind, carried=frozenset()):
146
+ entries = next((field for field in own if field.name == "entries"), None)
147
+ hidden = []
148
+ for field in own:
149
+ value = fields.get(field.dest)
150
+ if value is None or _keyed(field, entries) or not (
151
+ field.textual or field.parts or field.name == "unclaimed"):
152
+ continue
153
+ for member in value if field.repeated else [value]:
154
+ # @req> REQ-18950462@TJVbxYWG6Dcw 6ed4w7
155
+ if field.textual and not str(member).strip() and not (
156
+ field.name == "text" and kind in STATEMENTS):
157
+ # @req> REQ-30898926@Vu28eWaG_1mE 3vca72
158
+ raise ReqctlError(f"{field.flag} must not be blank -- give it "
159
+ "a value, or leave it out"
160
+ + (f"; on revise, {field.clears} clears the field"
161
+ if field.clears else ""))
162
+ # @req+ REQ-79956352@OsoTGRd6D5CZ i24q67
163
+ found = _validate.hidden(str(member))
164
+ if found is not None and not _restated(field, str(member), carried):
165
+ hidden.append(f"{field.flag} {_validate.hidden_fault(found)}")
166
+ # @req- i24q67
167
+ return hidden
168
+
169
+
170
+ def _stray(root, own, fields, operations=()):
171
+ mine = set(operations) | {dest for field in own
172
+ for dest in (field.dest, field.cleared, field.dropped)}
173
+ return sorted(flag.flag for flag in _fields.flags(root) + _fields.clears(root)
174
+ if flag.dest not in mine and fields.get(flag.dest) is not None)
175
+
176
+
177
+ def _converted(field, value):
178
+ if field.parts:
179
+ return criteria(value, field)
180
+ # @req> REQ-24481048@ZhiYUpKwnPlA 2k7l5j
181
+ if field.name == "unclaimed":
182
+ return _unclaimed(value)
183
+ if field.name == "word":
184
+ return str(value).strip()
185
+ if field.textual and field.repeated:
186
+ return [str(member).strip() for member in value]
187
+ return value
188
+
189
+
190
+ def _keyed(field, entries):
191
+ return entries is not None and (field is entries or field.name == "default")
192
+
193
+
194
+ def _one(values, flag, carried=frozenset()):
195
+ if len(values) > 1:
196
+ # @req> REQ-30898926@Vu28eWaG_1mE byxyqp
197
+ raise ReqctlError(f"{flag} states the whole set once -- pass {flag} "
198
+ "once, with every member inside one pair of brackets, "
199
+ "as in [json, markdown]")
200
+ return scalar(values[0], carried)
201
+
202
+
203
+ def _unclaimed(pairs):
204
+ held = {}
205
+ for pair in pairs:
206
+ phrase, split, reason = str(pair).partition("=")
207
+ if not phrase.strip() or not split:
208
+ # @req> REQ-30898926@Vu28eWaG_1mE zdqcku
209
+ raise ReqctlError(f"--unclaimed {pair!r}: the form is {FORMS['unclaimed']} "
210
+ "-- write the phrase, then =, then the reason")
211
+ held[" ".join(phrase.split())] = reason.strip()
212
+ return held
213
+
214
+
215
+ def criteria(given, field):
216
+ parsed = []
217
+ for raw in given:
218
+ parts = [p.strip() for p in raw.split("|")]
219
+ if len(parts) != len(field.parts) or not all(parts):
220
+ # @req> REQ-30898926@Vu28eWaG_1mE i4fgd4
221
+ raise ReqctlError(
222
+ f"{field.flag} {raw!r}: expected '{' | '.join(field.parts)}' -- "
223
+ f"write {len(field.parts)} non-empty parts separated by |"
224
+ )
225
+ parsed.append(dict(zip(field.parts, parts)))
226
+ return parsed
227
+
228
+
229
+ def _assessed_map(uid, data):
230
+ pins = data.get("assessed")
231
+ if pins is None:
232
+ return {}
233
+ if not isinstance(pins, dict):
234
+ # @req> REQ-30898926@Vu28eWaG_1mE 73c76c
235
+ raise ReqctlError(
236
+ f"{uid}: assessed is not a mapping, so this item's pins "
237
+ "cannot be changed without discarding them -- `reqctl validate` "
238
+ f"names the fault; edit {uid}'s file by hand so assessed maps each "
239
+ "address to its pin, or remove assessed"
240
+ )
241
+ return dict(pins)
242
+
243
+
244
+ def _prune_assessed(lookup, uid, data):
245
+ # @req+ REQ-54697016@Ch9-SdvPj8vq ncotff
246
+ wanted = set(corpus.pin_addresses(lookup, data))
247
+ data["assessed"] = {address: pin
248
+ for address, pin in _assessed_map(uid, data).items()
249
+ if address in wanted}
250
+ # @req- ncotff
251
+ return wanted
252
+
253
+
254
+ def _pruned(after, uids):
255
+ canonical = corpus.reachable(after)
256
+ for uid in uids:
257
+ held = dict(after[uid])
258
+ if isinstance(held.get("assessed"), dict):
259
+ _prune_assessed(lambda name: after.get(canonical.get(name, name)),
260
+ uid, held)
261
+ after[uid] = held
262
+
263
+
264
+ def _targets(data):
265
+ return set(corpus.concept_references(data)), set(corpus.references(data))
266
+
267
+
268
+ def _unknown_references(store, uid, data, carried):
269
+ faults = []
270
+ # @req+ REQ-44791607@6FX5SSp9MG3B mtmaxs
271
+ known = corpus.addressable(
272
+ {item.uid: item.data for item in corpus.items(store)})
273
+ linked, referenced = _targets(data)
274
+ held_links, held_references = carried
275
+ unknown = sorted({corpus.split_address(address)[0]
276
+ for address in referenced - held_references} - known)
277
+ if unknown:
278
+ # @req> REQ-30898926@Vu28eWaG_1mE dk5xj7
279
+ faults.append(
280
+ f"text references {', '.join(unknown)}, which does not exist -- "
281
+ "the parameter or data item must exist before the statement can "
282
+ "reference it; mint it with `reqctl new parameter --name NAME` or "
283
+ "`reqctl new data --name NAME`, or drop the reference"
284
+ )
285
+ absent = sorted({corpus.split_address(address)[0]
286
+ for address in linked - held_links} - known)
287
+ if absent:
288
+ # @req> REQ-30898926@Vu28eWaG_1mE rlcdi5
289
+ faults.append(
290
+ f"text links {', '.join(absent)}, which does not exist -- "
291
+ "it must exist before the statement can link it; mint it with "
292
+ "`reqctl new term --term WORD`, or write the words without the link"
293
+ )
294
+ # @req- mtmaxs
295
+ # @req> REQ-30898926@Vu28eWaG_1mE ff2nft
296
+ faults += [f"text contains {near.group()}, which is not a parameter "
297
+ f"reference -- write it in the form {corpus.NAME_FORM}"
298
+ for near in corpus.PARAM_NEAR.finditer(corpus.prose(data))
299
+ if not corpus.PARAM_REF.fullmatch(near.group())]
300
+ # @req> REQ-48500355@rnstkLKM48vN pctgzd
301
+ # @req> REQ-30898926@Vu28eWaG_1mE w52idy
302
+ faults += [f"text contains {near.group()} outside a link -- write a term "
303
+ f"as {corpus.LINK_FORM}"
304
+ for near in corpus.TERM_NEAR.finditer(corpus.unlinked_prose(
305
+ _validate.own_words(store.root, uid, data)))]
306
+ return faults
307
+
308
+
309
+ WIKI_LINK = re.compile(r"\[\[([^\[\]\n]+)\]\]")
310
+ ANY_LINK = re.compile(r"\[([^\]\n]+)\]\(([^()\n]+)\)")
311
+
312
+
313
+ def _name_index(store):
314
+ claimants, params, claimed = {}, {}, {}
315
+ for item in corpus.items(store):
316
+ held = corpus.name_of(item.uid, item.data)
317
+ if held is None:
318
+ continue
319
+ if corpus.kind_of(item.uid, item.data) == "term":
320
+ for word in _validate._term_words(item.data):
321
+ claimants.setdefault(word.casefold(), {})[held] = None
322
+ continue
323
+ first = claimed.setdefault(held, item.uid)
324
+ params[held] = held if first == item.uid else None
325
+ # @req+ REQ-57369684@hCyj1HdrHTr5 tbhiwa
326
+ terms = {word: next(iter(held)) if len(held) == 1 else None
327
+ for word, held in claimants.items()}
328
+ # @req- tbhiwa
329
+ return terms, params, claimants
330
+
331
+
332
+ def _addresses_match(store, address, ack):
333
+ held, rest = corpus.split_address(address)
334
+ wanted, wanted_rest = corpus.split_address(ack)
335
+ if held != wanted:
336
+ try:
337
+ if corpus.find(store, held) is not corpus.find(store, wanted):
338
+ return False
339
+ except ReqctlError:
340
+ return False
341
+ if wanted_rest is None:
342
+ return True
343
+ # @req+ REQ-11930677@dH7Pu7k4K4YI wgpma6
344
+ lookup = corpus.data_lookup(store)
345
+ asked = corpus.expand_address(lookup, f"{held}.{wanted_rest}") - {held}
346
+ if "." in wanted_rest and f"{held}.{wanted_rest}" not in asked:
347
+ return wanted_rest == rest
348
+ return bool(asked) and asked <= corpus.expand_address(lookup, address)
349
+ # @req- wgpma6
350
+
351
+
352
+ def resolve_names(store, text, index=None, carried=(frozenset(), frozenset())):
353
+ terms, params, claimants = index or _name_index(store)
354
+ identities = {held for held in (*terms.values(), *params.values()) if held}
355
+ linked, referenced = carried
356
+
357
+ def wiki(match):
358
+ word = match.group(1).strip().casefold()
359
+ uid = terms.get(word)
360
+ # @req> REQ-57369684@hCyj1HdrHTr5 oiqyws
361
+ if word in terms and uid is None:
362
+ # @req> REQ-30898926@Vu28eWaG_1mE 373yog
363
+ raise ReqctlError(
364
+ f"[[{match.group(1)}]]: the terms {' and '.join(claimants[word])} "
365
+ f"each claim it -- link the one you mean, as [{match.group(1)}]"
366
+ "(handle), or revise one term so it no longer claims the word"
367
+ )
368
+ if uid is None:
369
+ # @req> REQ-30898926@Vu28eWaG_1mE jlaqcl
370
+ raise ReqctlError(
371
+ f"[[{match.group(1)}]]: no term defines it -- mint the term "
372
+ "first with `reqctl new term --term WORD`, or write the word "
373
+ "without brackets"
374
+ )
375
+ return f"[{match.group(1)}]({uid})"
376
+
377
+ def link(match):
378
+ target = match.group(2).strip()
379
+ addressed = corpus.ADDRESS.match(target)
380
+ held = addressed.group(1) if addressed else target
381
+ if target in identities or params.get(held):
382
+ return match.group(0)
383
+ if addressed and corpus.kind_for_uid(held):
384
+ if held.startswith(("TERM-", "PARAM-", "DATA-")):
385
+ # @req> REQ-30898926@Vu28eWaG_1mE ntc6a4
386
+ raise ReqctlError(
387
+ f"[{match.group(1)}]({target}): a term, a parameter and a "
388
+ "data item are each addressed by the name they carry -- link "
389
+ "a term by its handle, or reference a parameter or data item "
390
+ f"as {corpus.NAME_FORM}; `reqctl list` names them"
391
+ )
392
+ noun = "requirement" if held.startswith("REQ-") else "guard"
393
+ # @req> REQ-30898926@Vu28eWaG_1mE u4gv5e
394
+ raise ReqctlError(
395
+ f"[{match.group(1)}]({target}): a link names a concept; a "
396
+ + noun + " is wired with `reqctl relate`, not linked -- write "
397
+ "the words without the link, and run `reqctl relate UID "
398
+ f"RELATION {held}`"
399
+ )
400
+ word = target.casefold()
401
+ uid = terms.get(word)
402
+ # @req> REQ-57369684@hCyj1HdrHTr5 kijh3h
403
+ if word in terms and uid is None:
404
+ # @req> REQ-30898926@Vu28eWaG_1mE 6m2au2
405
+ raise ReqctlError(
406
+ f"[{match.group(1)}]({target}): the terms "
407
+ f"{' and '.join(claimants[word])} each claim {target!r} -- "
408
+ "link the one you mean by its handle, or revise one term so it "
409
+ "no longer claims the word"
410
+ )
411
+ # @req> REQ-44791607@6FX5SSp9MG3B h2zt3s
412
+ if uid is None and target in linked:
413
+ return match.group(0)
414
+ if uid is None:
415
+ # @req> REQ-30898926@Vu28eWaG_1mE 2fwuyi
416
+ raise ReqctlError(
417
+ f"[{match.group(1)}]({target}): no term defines {target!r} -- "
418
+ "mint the term with `reqctl new term --term WORD`, or write the "
419
+ "words without the link"
420
+ )
421
+ return f"[{match.group(1)}]({uid})"
422
+
423
+ def reference(match):
424
+ name = match.group(1)
425
+ uid = params.get(name)
426
+ if name in params and uid is None:
427
+ # @req> REQ-30898926@Vu28eWaG_1mE flt4qg
428
+ raise ReqctlError(
429
+ f"${{{name}}}: more than one item claims the name -- "
430
+ "`reqctl validate` names them; give one another name with "
431
+ "`reqctl rename UID NAME`"
432
+ )
433
+ # @req> REQ-44791607@6FX5SSp9MG3B nadc6z
434
+ if uid is None and name + (match.group(2) or "") in referenced:
435
+ return match.group(0)
436
+ if uid is None:
437
+ # @req> REQ-30898926@Vu28eWaG_1mE zejpjo
438
+ raise ReqctlError(
439
+ f"${{{name}}}: no parameter or data item named {name!r} -- "
440
+ f"mint it with `reqctl new parameter --name {name}` or `reqctl "
441
+ f"new data --name {name}`, or drop the reference"
442
+ )
443
+ return f"${{{uid}{match.group(2) or ''}}}"
444
+
445
+ resolved = corpus.PARAM_REF.sub(
446
+ reference, ANY_LINK.sub(link, WIKI_LINK.sub(wiki, text)))
447
+ if "[[" in resolved or "]]" in resolved:
448
+ # @req> REQ-30898926@Vu28eWaG_1mE ehjjod
449
+ raise ReqctlError(
450
+ "text holds [[ or ]] outside a resolved link, a nested or "
451
+ "unmatched bracket -- close or remove the stray bracket"
452
+ )
453
+ for token in re.finditer(r"\$\{[^}]*\}?", resolved):
454
+ if not corpus.PARAM_REF.fullmatch(token.group()):
455
+ # @req> REQ-30898926@Vu28eWaG_1mE otgqko
456
+ raise ReqctlError(
457
+ f"text contains {token.group()}, which is not a parameter "
458
+ f"reference -- write it in the form {corpus.NAME_FORM}"
459
+ )
460
+ return resolved
461
+
462
+
463
+ # @req> REQ-76604940@SEVyiDrbH-Ez 25cppk
464
+ def _resolved_prose(store, uid, data, carried=(frozenset(), frozenset())):
465
+ paths = set(_validate.prose_paths(store.root, uid, data))
466
+ index = _name_index(store)
467
+ return corpus.prosed(data, paths,
468
+ lambda text: resolve_names(store, text, index, carried))
469
+
470
+
471
+ def entry_key(member):
472
+ if isinstance(member, bool):
473
+ return "true" if member else "false"
474
+ return str(member)
475
+
476
+
477
+ def _held(label, text, carried, verbatim):
478
+ if not verbatim:
479
+ return scalar(text, carried)
480
+ _refuse_hidden(label, text, carried)
481
+ return text.strip()
482
+
483
+
484
+ def _verbatim_steps(store, uid, prospective, named, key, fields):
485
+ place = named.rpartition(".")[0] or "entries"
486
+ entry = dict(prospective[place].get(key) or {})
487
+ stepped = [tuple(step.strip() for step in str(pair).partition("=")[0].strip().split("."))
488
+ for pair in [*(fields.get("set") or []), *(fields.get("append") or [])]]
489
+ for steps in stepped:
490
+ where = entry
491
+ for step in steps[:-1]:
492
+ nested = where.get(step)
493
+ where[step] = nested = dict(nested) if isinstance(nested, dict) else {}
494
+ where = nested
495
+ where[steps[-1]] = ""
496
+ probe = {**prospective, place: {**prospective[place], key: entry}}
497
+ marked = _validate.verbatim_paths(store.root, uid, probe)
498
+ return frozenset(steps for steps in stepped if (place, key, *steps) in marked)
499
+
500
+
501
+ def _set_fields(entry, pairs, carried, verbatim=frozenset()):
502
+ for pair in pairs:
503
+ name, split, value = str(pair).partition("=")
504
+ if not name.strip() or not split:
505
+ # @req> REQ-30898926@Vu28eWaG_1mE idy5kb
506
+ raise ReqctlError(f"--set {pair!r}: the form is field=value -- "
507
+ "write the field, then =, then the value")
508
+ steps = [step.strip() for step in name.strip().split(".")]
509
+ if not all(steps):
510
+ # @req> REQ-30898926@Vu28eWaG_1mE geao3l
511
+ raise ReqctlError(
512
+ f"--set {name.strip()!r}: a dotted field names the entries a "
513
+ "field holds -- name a step between every pair of dots, as "
514
+ "properties.date.name"
515
+ )
516
+ # @req> REQ-79956352@OsoTGRd6D5CZ qldnjy
517
+ for said in name.split("."):
518
+ _refuse_hidden(f"--set {name.strip()!r}:", said.strip(" "), carried)
519
+ where = entry
520
+ for step in steps[:-1]:
521
+ nested = where.get(step, {})
522
+ if not isinstance(nested, dict):
523
+ # @req> REQ-30898926@Vu28eWaG_1mE 6hmnnw
524
+ raise ReqctlError(
525
+ f"--set {name.strip()}: {step} holds a value, not entries -- "
526
+ f"set {step} itself rather than a field under it"
527
+ )
528
+ where[step] = nested = dict(nested)
529
+ where = nested
530
+ # @req> REQ-37302987@mgBn704avyaM 6zdpuv
531
+ # @req> REQ-31873240@K0ddnolUc2IX c3ycu6
532
+ held = _held(f"--set {name.strip()!r}:", value, carried, tuple(steps) in verbatim)
533
+ where[steps[-1]] = held
534
+ return entry
535
+
536
+
537
+ def _appended(where, steps):
538
+ for step in steps[:-1]:
539
+ nested = where.get(step, {})
540
+ if not isinstance(nested, dict):
541
+ # @req> REQ-30898926@Vu28eWaG_1mE qumxgk
542
+ raise ReqctlError(
543
+ f"--append {'.'.join(steps)}: {step} holds a value, not entries "
544
+ f"-- name {step} itself rather than a field under it"
545
+ )
546
+ where[step] = nested = dict(nested)
547
+ where = nested
548
+ held = where.get(steps[-1])
549
+ if held is not None and not isinstance(held, list):
550
+ # @req> REQ-30898926@Vu28eWaG_1mE li7im2
551
+ raise ReqctlError(
552
+ f"--append {'.'.join(steps)}: {steps[-1]} holds a value, not a set "
553
+ f"-- replace the value with --set {'.'.join(steps)}=VALUE"
554
+ )
555
+ return where, list(held or [])
556
+
557
+
558
+ def _append_fields(entry, pairs, carried, verbatim=frozenset()):
559
+ for pair in pairs:
560
+ name, split, value = str(pair).partition("=")
561
+ if not name.strip() or not split:
562
+ # @req> REQ-30898926@Vu28eWaG_1mE tqbymt
563
+ raise ReqctlError(f"--append {pair!r}: the form is field=value -- "
564
+ "write the field, then =, then the value")
565
+ steps = [step.strip() for step in name.strip().split(".")]
566
+ if not all(steps):
567
+ # @req> REQ-30898926@Vu28eWaG_1mE hmdsjr
568
+ raise ReqctlError(
569
+ f"--append {name.strip()!r}: a dotted field names the entries a "
570
+ "field holds -- name a step between every pair of dots, as "
571
+ "properties.date.name"
572
+ )
573
+ # @req> REQ-79956352@OsoTGRd6D5CZ gey5jb
574
+ for said in name.split("."):
575
+ _refuse_hidden(f"--append {name.strip()!r}:", said.strip(" "), carried)
576
+ # @req> REQ-37302987@mgBn704avyaM lqbt6n
577
+ # @req> REQ-31873240@K0ddnolUc2IX a533m7
578
+ member = _held(f"--append {name.strip()!r}:", value, carried, tuple(steps) in verbatim)
579
+ if isinstance(member, list):
580
+ # @req> REQ-30898926@Vu28eWaG_1mE b4xdt6
581
+ raise ReqctlError(
582
+ f"--append {name.strip()}: a set is appended a member at a "
583
+ "time; repeat --append"
584
+ )
585
+ where, held = _appended(entry, steps)
586
+ # @req+ REQ-55564856@yOTVGvtwjD3l ej4esr
587
+ held = [member if str(kept).strip() == str(member) else kept for kept in held]
588
+ where[steps[-1]] = held if any(corpus.same(kept, member) for kept in held) else held + [member]
589
+ # @req- ej4esr
590
+ return entry
591
+
592
+
593
+ def _unset_fields(entry, names, key):
594
+ for name in names:
595
+ gone = str(name).strip()
596
+ steps = [step.strip() for step in gone.split(".")]
597
+ if not all(steps):
598
+ # @req> REQ-30898926@Vu28eWaG_1mE vzhjts
599
+ raise ReqctlError(
600
+ f"--unset {gone!r}: a dotted field names the entries a field "
601
+ "holds -- name a step between every pair of dots, as "
602
+ "properties.date.name"
603
+ )
604
+ where = entry
605
+ for step in steps[:-1]:
606
+ nested = where.get(step)
607
+ if not isinstance(nested, dict):
608
+ # @req> REQ-30898926@Vu28eWaG_1mE ocglhp
609
+ raise ReqctlError(f"--unset {gone}: {step} holds no entries -- "
610
+ "name a field the entry records, as `reqctl "
611
+ "context` shows them")
612
+ where[step] = nested = dict(nested)
613
+ where = nested
614
+ if steps[-1] not in where:
615
+ # @req> REQ-30898926@Vu28eWaG_1mE 3nzrtm
616
+ raise ReqctlError(
617
+ f"--unset {gone}: not a field of {'.'.join([key, *steps[:-1]])} "
618
+ "-- name a field it records, as `reqctl context` shows them"
619
+ )
620
+ where.pop(steps[-1])
621
+ return entry
622
+
623
+
624
+ def _article_for(word):
625
+ return "an" if word[:1].lower() in "aeiou" else "a"
626
+
627
+
628
+ def _rename_map(before, fields, now):
629
+ was = corpus.term_word(before)
630
+ words = {was: now} if was and now and was != now else {}
631
+ for pair in fields.get("reword") or []:
632
+ old, _, new = str(pair).partition("=")
633
+ old, new = old.strip(), new.strip()
634
+ if not old or not new:
635
+ # @req> REQ-30898926@Vu28eWaG_1mE tbiwhv
636
+ raise ReqctlError(f"--reword {pair!r}: the form is old=new -- "
637
+ "write the words a link shows, then =, then the new words")
638
+ # @req> REQ-79956352@OsoTGRd6D5CZ uafjgl
639
+ _refuse_hidden("--reword", pair)
640
+ words[old] = new
641
+ for new in words.values():
642
+ stray = next((c for c in new if c in "]\n"), None)
643
+ if stray is not None:
644
+ # @req> REQ-30898926@Vu28eWaG_1mE clek7u
645
+ raise ReqctlError(
646
+ f"{new!r} holds U+{ord(stray):04X}, which a link's visible text "
647
+ f"cannot carry, as {corpus.LINK_FORM} ends at the first of it -- "
648
+ "choose words without it"
649
+ )
650
+ return words
651
+
652
+
653
+ def _reworded(text, addresses, words, seen):
654
+ def swap(match):
655
+ article, gap, shown, target = match.groups()
656
+ if target not in addresses:
657
+ return match.group(0)
658
+ new = words.get(shown)
659
+ asked = shown
660
+ if new is None and shown[:1].isupper():
661
+ asked = shown[:1].lower() + shown[1:]
662
+ held = words.get(asked)
663
+ new = held and held[:1].upper() + held[1:]
664
+ if new is None:
665
+ seen["left"].add(shown)
666
+ return match.group(0)
667
+ seen["placed"].add(asked)
668
+ seen["done"] += 1
669
+ if not article:
670
+ return f"[{new}]({target})"
671
+ want = _article_for(new)
672
+ if want == article.lower():
673
+ want = article
674
+ else:
675
+ if article[:1].isupper():
676
+ want = want[:1].upper() + want[1:]
677
+ seen["articles"].append(f"{article} {shown} -> {want} {new}")
678
+ return f"{want}{gap}[{new}]({target})"
679
+
680
+ return ARTICLED_LINK.sub(swap, str(text))
681
+
682
+
683
+ # @req+ REQ-67358511@6v5ZhXIOqAkD b3ts36
684
+ def _reword_data(root, uid, data, addresses, words, seen):
685
+ return corpus.prosed(data, set(_validate.prose_paths(root, uid, data)),
686
+ lambda text: _reworded(text, addresses, words, seen))
687
+ # @req- b3ts36
688
+
689
+
690
+ def _repin(after, touched, keep=frozenset()):
691
+ moved = {}
692
+ for uid, data in after.items():
693
+ pins = data.get("assessed")
694
+ if not isinstance(pins, dict):
695
+ continue
696
+ held = dict(pins)
697
+ for address, pin in pins.items():
698
+ target, rest = corpus.split_address(address)
699
+ if target not in touched or target not in after or (uid, address) in keep:
700
+ continue
701
+ fresh = corpus.address_stamp_of(target, after[target], rest)
702
+ if fresh is not None and fresh != pin:
703
+ held[address] = fresh
704
+ if held != pins:
705
+ moved[uid] = dict(data, assessed=held)
706
+ return moved
707
+
708
+
709
+ def _awaiting(before, uid, olds, new):
710
+ return {(holder, f"{new}.{rest}" if rest else new)
711
+ for holder, data in before.items()
712
+ for address, pin in corpus.mapping(data, "assessed").items()
713
+ for target, rest in [corpus.split_address(address)]
714
+ if target in olds
715
+ and pin != corpus.address_stamp_of(uid, before[uid], rest)}
716
+
717
+
718
+ def _propagate(store, uid, prospective, words):
719
+ before = {item.uid: item.data for item in corpus.items(store)}
720
+ addresses = {uid, corpus.name_of(uid, prospective)} - {None}
721
+ seen = {"done": 0, "left": set(), "placed": set(), "articles": [],
722
+ "moved": []}
723
+ after = dict(before)
724
+ touched = set()
725
+ for held_uid, data in before.items():
726
+ was = prospective if held_uid == uid else data
727
+ if not any(f"]({address})" in str(was) for address in addresses):
728
+ continue
729
+ held = _reword_data(store.root, held_uid, was, addresses, words, seen)
730
+ if held != was:
731
+ after[held_uid] = held
732
+ touched.add(held_uid)
733
+ after[uid] = prospective if uid not in touched else after[uid]
734
+ # @req> REQ-54697016@Ch9-SdvPj8vq mzmppv
735
+ _pruned(after, touched)
736
+ found = (_validate.coherence(after, store.root)
737
+ + _validate._unlinked_terms(after, store.root))
738
+ stood = (_validate.coherence(before, store.root)
739
+ + _validate._unlinked_terms(before, store.root)) if found else []
740
+ faults = [fault for fault in found if fault not in stood]
741
+ if faults:
742
+ raise ReqctlError("\n".join(faults))
743
+ fields = corpus.term_fields(after[uid])
744
+ seen["left"] -= {corpus.term_word(after[uid]), *(fields.get("aliases") or [])}
745
+ seen["moved"] = [
746
+ corpus.Item(held_uid, corpus.find(store, held_uid).path, after[held_uid])
747
+ for held_uid in sorted(touched) if held_uid != uid
748
+ ]
749
+ return after[uid], seen
750
+
751
+
752
+ def _readdressed(value, olds, new):
753
+ if isinstance(value, str):
754
+ def link(match):
755
+ held, _, rest = match.group(2).partition(".")
756
+ if held not in olds:
757
+ return match.group(0)
758
+ return f"[{match.group(1)}]({new}{'.' + rest if rest else ''})"
759
+
760
+ def reference(match):
761
+ if match.group(1) not in olds:
762
+ return match.group(0)
763
+ return "${" + new + (match.group(2) or "") + "}"
764
+
765
+ return corpus.PARAM_REF.sub(
766
+ reference, corpus.CONCEPT_LINK.sub(link, value))
767
+ if isinstance(value, list):
768
+ return [_readdressed(each, olds, new) for each in value]
769
+ if isinstance(value, dict):
770
+ return {key: _readdressed(each, olds, new) for key, each in value.items()}
771
+ return value
772
+
773
+
774
+ def _readdressed_item(data, olds, new):
775
+ held = {key: value if key in ("assessed", "relations")
776
+ else _readdressed(value, olds, new)
777
+ for key, value in data.items()}
778
+ # @req+ REQ-11930677@dH7Pu7k4K4YI yycc7l
779
+ pins = data.get("assessed")
780
+ if isinstance(pins, dict):
781
+ moved = {}
782
+ for address, pin in pins.items():
783
+ target, _, rest = str(address).partition(".")
784
+ key = (f"{new}.{rest}" if rest else new) if target in olds else address
785
+ moved[key] = pin
786
+ held["assessed"] = moved
787
+ # @req- yycc7l
788
+ return held
789
+
790
+
791
+ def _carrying(uid, data, old_name, new_name):
792
+ held = [one for one in corpus.carried_of(uid, data) if one != new_name]
793
+ if old_name is not None and old_name not in held:
794
+ held.append(old_name)
795
+ return dict(data, carried=held)
796
+
797
+
798
+ def _named(data, kind, new_name):
799
+ if kind != "term":
800
+ return dict(data, name=new_name)
801
+ held = corpus.entries(data) or {}
802
+ word = next(iter(held), None)
803
+ return dict(data, entries={new_name: held[word]})
804
+
805
+
806
+ # @req> REQ-53177302@oSukBFyA3jXq 7bhax4
807
+ def _refuse_unusable_name(store, name, held=None):
808
+ if not corpus.DATA_KEY.match(name):
809
+ # @req> REQ-30898926@Vu28eWaG_1mE 4x2722
810
+ raise ReqctlError(f"{name}: a name is snake_case -- write it in "
811
+ "lowercase words joined by _, as stated_name")
812
+ for other in corpus.items(store):
813
+ if other.uid == held or corpus.name_of(other.uid, other.data) != name:
814
+ continue
815
+ kind = corpus.kind_of(other.uid, other.data)
816
+ unchanged = f"; {held} was not changed" if held else ""
817
+ # @req> REQ-30898926@Vu28eWaG_1mE dbxarp
818
+ raise ReqctlError(
819
+ f"{name}: the {kind} {other.uid} already answers to it -- choose "
820
+ f"another name, or rename that one first with `reqctl rename "
821
+ f"{other.uid} NAME`{unchanged}")
822
+
823
+
824
+ def rename(store, uid, new_name):
825
+ item = corpus.find(store, uid)
826
+ kind = corpus.kind_of(item.uid, item.data)
827
+ if kind not in ("term", "parameter", "data"):
828
+ # @req> REQ-30898926@Vu28eWaG_1mE olyxh6
829
+ raise ReqctlError(
830
+ f"{item.uid}: a {kind} carries no name; a term, a parameter or a "
831
+ "data item is renamed -- name one of those instead")
832
+ # @req> REQ-79956352@OsoTGRd6D5CZ 75mdrs
833
+ _refuse_hidden("name", new_name)
834
+ _refuse_unusable_name(store, new_name, item.uid)
835
+ # @req+ REQ-99037710@COBChae3SzWZ mbvtpn
836
+ wore = [held for held in sorted(corpus.carriers(store).get(new_name, []))
837
+ if held != item.uid]
838
+ if wore:
839
+ # @req> REQ-30898926@Vu28eWaG_1mE n6hriu
840
+ raise ReqctlError(
841
+ f"{new_name}: {', '.join(wore)} has carried that name, so a "
842
+ f"reference to it would be ambiguous -- choose another; {item.uid} "
843
+ "was not changed")
844
+ # @req- mbvtpn
845
+ old_name = corpus.name_of(item.uid, item.data)
846
+ olds = {item.uid} | ({old_name} if old_name is not None else set())
847
+ if old_name == new_name and item.path.stem == new_name:
848
+ # @req> REQ-30898926@Vu28eWaG_1mE egtnqu
849
+ raise ReqctlError(f"{item.uid} already answers to {new_name} -- "
850
+ "name a different name")
851
+
852
+ # @req+ REQ-97543346@Yc-UcdtSPtB3 jjk7ud
853
+ before = {held.uid: held.data for held in corpus.items(store)}
854
+ # @req> REQ-23995596@-pejAoHYKY9L fdfvpm
855
+ after = {held_uid: _readdressed_item(data, olds, new_name)
856
+ for held_uid, data in before.items()}
857
+ # @req- jjk7ud
858
+ # @req> REQ-94917590@2yP1VjEjzn4- 7xvisp
859
+ after[item.uid] = _named(
860
+ _carrying(item.uid, after[item.uid], old_name, new_name),
861
+ kind, new_name)
862
+ after[new_name] = after.pop(item.uid)
863
+ # @req> REQ-54697016@Ch9-SdvPj8vq rvmjri
864
+ _pruned(after, [held_uid for held_uid, data in after.items()
865
+ if data != before.get(held_uid)])
866
+ # @req> REQ-14368186@0pLRg6eJoadP mgzb3c
867
+ after.update(_repin(after, {new_name}, _awaiting(before, item.uid, olds, new_name)))
868
+
869
+ # @req> REQ-81815730@fForAOZqtxKN k27etl
870
+ faults = [fault for fault in _validate.dictionary_rules(new_name, after[new_name])
871
+ if fault not in _validate.dictionary_rules(new_name, item.data)]
872
+ # @req+ REQ-66534921@BYEU0L2sI9q_ 2dp56x
873
+ schema = corpus.schema_path(store.root, old_name) if kind == "data" and old_name else None
874
+ if schema and old_name != new_name and schema.is_file():
875
+ corpus.atomic_write(corpus.schema_path(store.root, new_name), schema.read_bytes())
876
+ corpus.remove(schema)
877
+ # @req- 2dp56x
878
+ found = _validate.coherence(after, store.root)
879
+ stood = _validate.coherence(before, store.root) if found else []
880
+ faults += [fault for fault in found if fault not in stood]
881
+ if faults:
882
+ raise ReqctlError("\n".join(faults) + f"\n{item.uid} was not changed")
883
+
884
+ paths = {held.uid: held.path for held in corpus.items(store)}
885
+ # @req> REQ-49112003@ntC6kZ4OX71X k66gqp
886
+ moved = corpus.folder_for(store.root, kind) / f"{new_name}.yml"
887
+ corpus.save(store, corpus.Item(new_name, moved, after[new_name]))
888
+ if item.path != moved:
889
+ corpus.remove(item.path)
890
+ for held_uid, data in after.items():
891
+ if held_uid == new_name or data == before.get(held_uid):
892
+ continue
893
+ corpus.save(store, corpus.Item(held_uid, paths[held_uid], data))
894
+ corpus.invalidate(store)
895
+ return sorted(held_uid for held_uid, data in after.items()
896
+ if held_uid != new_name and data != before.get(held_uid))
897
+
898
+
899
+ def refile(store, uid):
900
+ item = corpus.find(store, uid)
901
+ held = corpus.kind_of(item.uid, item.data)
902
+ if held != "parameter":
903
+ # @req> REQ-30898926@Vu28eWaG_1mE xuf4dj
904
+ raise ReqctlError(
905
+ f"{item.uid} is a {held}; refile files a parameter as a data item -- "
906
+ "name a parameter; `reqctl list parameter` names them")
907
+
908
+ before = {found.uid: found.data for found in corpus.items(store)}
909
+ after = dict(before)
910
+ dropped = (_fields.names(store.root, "parameter")
911
+ - _fields.names(store.root, "data"))
912
+ after[item.uid] = dict(
913
+ {key: value for key, value in item.data.items()
914
+ if key not in dropped}, kind="data")
915
+ # @req> REQ-14368186@0pLRg6eJoadP p2igyc
916
+ after.update(_repin(after, {item.uid},
917
+ _awaiting(before, item.uid, {item.uid}, item.uid)))
918
+
919
+ problems = _validate.dictionary_rules(item.uid, after[item.uid])
920
+ found = _validate.coherence(after, store.root)
921
+ stood = _validate.coherence(before, store.root) if found else []
922
+ problems += [fault for fault in found if fault not in stood]
923
+ if problems:
924
+ raise ReqctlError("\n".join(problems) + f"\n{item.uid} was not changed")
925
+
926
+ moved = corpus.folder_for(store.root, "data") / f"{item.uid}.yml"
927
+ corpus.save(store, corpus.Item(item.uid, moved, after[item.uid]))
928
+ if item.path != moved:
929
+ corpus.remove(item.path)
930
+ for held_uid, data in after.items():
931
+ if held_uid == item.uid or data == before.get(held_uid):
932
+ continue
933
+ corpus.save(store, corpus.Item(
934
+ held_uid, corpus.find(store, held_uid).path, data))
935
+ corpus.invalidate(store)
936
+ return sorted(held_uid for held_uid, data in after.items()
937
+ if held_uid != item.uid and data != before.get(held_uid))
938
+
939
+
940
+ def typed(store):
941
+ try:
942
+ held = corpus.find(store, _validate.VALUE_TYPES)
943
+ except ReqctlError:
944
+ return False
945
+ return corpus.kind_of(held.uid, held.data) == "data"
946
+
947
+
948
+ def _filed(value, olds, address, values):
949
+ if isinstance(value, str):
950
+ def reference(match):
951
+ head, _, tail = (match.group(2) or ".")[1:].partition(".")
952
+ if match.group(1) not in olds or head not in ("", "default", *map(str, values)):
953
+ return match.group(0)
954
+ return "${" + address + ("." + tail if tail else "") + "}"
955
+
956
+ return corpus.PARAM_REF.sub(reference, value)
957
+ if isinstance(value, list):
958
+ return [_filed(each, olds, address, values) for each in value]
959
+ if isinstance(value, dict):
960
+ return {key: _filed(each, olds, address, values) for key, each in value.items()}
961
+ return value
962
+
963
+
964
+ def _filed_item(data, olds, address, values):
965
+ held = _filed(data, olds, address, values)
966
+ pins = data.get("assessed")
967
+ if isinstance(pins, dict):
968
+ held["assessed"] = {}
969
+ for pinned, pin in pins.items():
970
+ if str(pinned).partition(".")[0] not in olds:
971
+ held["assessed"][pinned] = pin
972
+ continue
973
+ held["assessed"][address] = pin
974
+ held["assessed"].setdefault(address.partition(".")[0], pin)
975
+ return held
976
+
977
+
978
+ def refile_into(store, uid, into, key):
979
+ item = corpus.find(store, uid)
980
+ held = corpus.kind_of(item.uid, item.data)
981
+ if held != "parameter":
982
+ # @req> REQ-30898926@Vu28eWaG_1mE aplnce
983
+ raise ReqctlError(
984
+ f"{item.uid} is a {held}; refile files a parameter as a data item -- "
985
+ "name a parameter; `reqctl list parameter` names them")
986
+ # @req> REQ-47334951@68J7gRTY6uvK yw2ky4
987
+ if not typed(store):
988
+ # @req> REQ-30898926@Vu28eWaG_1mE 7s76xc
989
+ raise ReqctlError(
990
+ f"the corpus holds no data item named {_validate.VALUE_TYPES} -- "
991
+ f"mint it with `reqctl new data --name {_validate.VALUE_TYPES}`, or "
992
+ f"file {item.uid} as a data item of its own with `reqctl refile "
993
+ f"{item.uid}`; {item.uid} was not changed")
994
+ # @req+ REQ-32527326@JhrCBejivbr9 44gp2u
995
+ target = corpus.find(store, into)
996
+ held = corpus.kind_of(target.uid, target.data)
997
+ if held != "data":
998
+ # @req> REQ-30898926@Vu28eWaG_1mE juki7h
999
+ raise ReqctlError(
1000
+ f"{target.uid} is a {held}; a parameter is filed into a data item -- "
1001
+ "name a data item after --into; `reqctl list data` names them; "
1002
+ f"{item.uid} was not changed")
1003
+ stated = item.data.get("entries")
1004
+ values = list(stated) if isinstance(stated, (dict, list)) else [stated] * (stated is not None)
1005
+ if len(values) > 1:
1006
+ # @req> REQ-30898926@Vu28eWaG_1mE 2pcwe7
1007
+ raise ReqctlError(
1008
+ f"{item.uid} holds {', '.join(map(str, values))}; only a parameter "
1009
+ f"holding one value is filed as an entry -- file it as a data item of "
1010
+ f"its own with `reqctl refile {item.uid}`; {item.uid} was not changed")
1011
+ if item.data.get("default") is not None:
1012
+ # @req> REQ-30898926@Vu28eWaG_1mE ploxif
1013
+ raise ReqctlError(
1014
+ f"{item.uid} states a default, which an entry cannot carry -- "
1015
+ f"drop it with `reqctl revise {item.uid} --no-default`, or file it "
1016
+ f"as a data item of its own with `reqctl refile {item.uid}`; "
1017
+ f"{item.uid} was not changed")
1018
+ entries = target.data.get("entries", {})
1019
+ if not isinstance(entries, dict):
1020
+ # @req> REQ-30898926@Vu28eWaG_1mE pue73u
1021
+ raise ReqctlError(
1022
+ f"{target.uid} does not hold its entries under keys -- name a data "
1023
+ "item that keys its entries after --into, or edit "
1024
+ f"{target.uid}'s file by hand to key them; {item.uid} was not changed")
1025
+ if key in map(str, entries):
1026
+ # @req> REQ-30898926@Vu28eWaG_1mE h3nhv2
1027
+ raise ReqctlError(
1028
+ f"{target.uid} already holds {key} -- choose another key; "
1029
+ f"{item.uid} was not changed")
1030
+
1031
+ address = f"{target.uid}.{key}"
1032
+ if not corpus.PARAM_REF.fullmatch("${" + address + "}"):
1033
+ # @req> REQ-30898926@Vu28eWaG_1mE d5msl2
1034
+ raise ReqctlError(
1035
+ f"{address} is not an address a reference can carry -- choose a "
1036
+ f"snake_case key; {item.uid} was not changed")
1037
+ olds = {item.uid, corpus.name_of(item.uid, item.data)}
1038
+ paths = {found.uid: found.path for found in corpus.items(store)}
1039
+ before = {found.uid: found.data for found in corpus.items(store)}
1040
+ after = {held_uid: _filed_item(data, olds, address, values)
1041
+ for held_uid, data in before.items()}
1042
+ carried = corpus.mapping(after[item.uid], "assessed")
1043
+ after[target.uid] = dict(
1044
+ after[target.uid],
1045
+ **({"assessed": {**carried, **corpus.mapping(after[target.uid], "assessed")}}
1046
+ if carried else {}),
1047
+ entries={**(corpus.entries(after[target.uid]) or {}), key: {
1048
+ "definition": after[item.uid].get("text"),
1049
+ "value_type": item.data.get("value_type"),
1050
+ **({"quantity": values[0]} if values else {}),
1051
+ **({"unit": item.data["unit"]} if item.data.get("unit") is not None else {})}})
1052
+ after[item.uid] = dict(after[item.uid], status="deprecated")
1053
+ moved = {held_uid for held_uid, data in after.items() if data != before[held_uid]}
1054
+ after.update(_repin(after, moved - {target.uid, item.uid}))
1055
+ # @req+ REQ-14368186@0pLRg6eJoadP yrfrdz
1056
+ awaiting = {holder for holder, _ in _awaiting(before, item.uid, olds, address)}
1057
+ after.update({held_uid: data for held_uid, data in _repin(after, {target.uid}, {
1058
+ (holder, pinned) for holder in awaiting for pinned in (address, target.uid)
1059
+ if pinned not in corpus.mapping(before[holder], "assessed")}
1060
+ | _awaiting(before, target.uid, {target.uid}, target.uid)).items()
1061
+ if held_uid in moved})
1062
+ # @req- yrfrdz
1063
+
1064
+ # @req+ REQ-42390302@QOC4GhS7SkFg aev5ks
1065
+ written = [corpus.Item(held_uid, paths[held_uid], data)
1066
+ for held_uid, data in sorted(after.items()) if data != before[held_uid]]
1067
+ found = _validate.coherence(after, store.root)
1068
+ stood = _validate.coherence(before, store.root) if found else []
1069
+ problems = _new_item_faults(store, written) + [
1070
+ fault for fault in found if fault not in stood]
1071
+ if problems:
1072
+ raise ReqctlError("\n".join(problems) + f"\n{item.uid} was not changed")
1073
+ # @req- aev5ks
1074
+
1075
+ for filed in written:
1076
+ corpus.save(store, filed)
1077
+ corpus.invalidate(store)
1078
+ return address, sorted(moved - {item.uid, target.uid})
1079
+ # @req- 44gp2u
1080
+
1081
+
1082
+ def _entered(value):
1083
+ if not isinstance(value, list):
1084
+ separator = _listing(entry_key(value))
1085
+ if separator is not None:
1086
+ # @req> REQ-30898926@Vu28eWaG_1mE mv6opl
1087
+ raise ReqctlError(
1088
+ f"{value!r}: a set needs its brackets, as without them "
1089
+ f"U+{ord(separator):04X} is not a separator but part of the one "
1090
+ "member's name -- write it inside brackets, as in [json, markdown]"
1091
+ )
1092
+ # @req+ REQ-42544082@VRPSEgM9tJWs ghvzto
1093
+ members = value if isinstance(value, list) else [value]
1094
+ if len(set(members)) != len(members):
1095
+ # @req> REQ-30898926@Vu28eWaG_1mE x7dnbk
1096
+ raise ReqctlError("a set member is duplicated -- name each member once")
1097
+ keys = [entry_key(member) for member in members]
1098
+ if len(set(keys)) != len(keys):
1099
+ # @req> REQ-30898926@Vu28eWaG_1mE olur2z
1100
+ raise ReqctlError("a set member is duplicated -- name each member once")
1101
+ # @req- ghvzto
1102
+ return {key: {} for key in keys}
1103
+
1104
+
1105
+ def _taken(folder, uid):
1106
+ return any((folder / f"{uid}{suffix}").exists() for suffix in (".yml", ".yaml"))
1107
+
1108
+
1109
+ # @req> REQ-57781505@5YGB5n_NZmnc cmxvxt
1110
+ def _placeholder_for(kind, uid):
1111
+ prefix = KINDS[kind]
1112
+ given = GIVEN_UID.match(uid)
1113
+ if given is None or given.group(1) != prefix:
1114
+ # @req> REQ-30898926@Vu28eWaG_1mE qnl3yk
1115
+ raise ReqctlError(
1116
+ f"{uid}: a {kind} is minted under {prefix}- and eight digits -- "
1117
+ f"pass --uid {prefix}-NNNNNNNN, or leave --uid out to mint a free one")
1118
+ return uid.removeprefix(f"{prefix}-")
1119
+
1120
+
1121
+ def mint(store, kind, name=None, placeholder=None):
1122
+ folder = corpus.folder_for(store.root, kind)
1123
+ if name is not None:
1124
+ _refuse_unusable_name(store, name)
1125
+ # @req+ REQ-90857221@Bj85mwvDJhME cv2nmd
1126
+ wore = sorted(corpus.carriers(store).get(name, []))
1127
+ if wore:
1128
+ # @req> REQ-30898926@Vu28eWaG_1mE ttjwaa
1129
+ raise ReqctlError(
1130
+ f"{name}: {', '.join(wore)} has carried that name -- choose "
1131
+ "another name")
1132
+ # @req- cv2nmd
1133
+ # @req> REQ-49112003@ntC6kZ4OX71X gwr3km
1134
+ return name, folder / f"{name}.yml"
1135
+ prefix = KINDS[kind]
1136
+ # @req+ REQ-73115701@_3WJxxfhIo7L j3ijco
1137
+ if placeholder is not None:
1138
+ uid = f"{prefix}-{placeholder}"
1139
+ # @req> REQ-64236823@S--_1RHFn9pW 5xsesf
1140
+ if _taken(folder, uid):
1141
+ # @req> REQ-30898926@Vu28eWaG_1mE ohel7y
1142
+ raise ReqctlError(f"{uid}: the corpus already holds an item under it "
1143
+ "-- pass another --uid, or leave --uid out to "
1144
+ "mint a free one")
1145
+ return uid, folder / f"{uid}.yml"
1146
+ # @req- j3ijco
1147
+ rng = random.SystemRandom()
1148
+ for _ in range(MINT_ATTEMPTS):
1149
+ uid = f"{prefix}-{rng.randrange(10_000_000, 100_000_000)}"
1150
+ if not _taken(folder, uid):
1151
+ return uid, folder / f"{uid}.yml"
1152
+ # @req> REQ-30898926@Vu28eWaG_1mE pk7vas
1153
+ raise ReqctlError(f"could not mint a free UID in {MINT_ATTEMPTS} attempts "
1154
+ "-- run the command again")
1155
+
1156
+
1157
+ def prepare(store, kind, fields, placeholder=None):
1158
+ own = _fields.of(store.root, kind)
1159
+ # @req+ REQ-48998966@TUTQjlZtGDUP 4zgzmu
1160
+ missing = [field.flag for field in own
1161
+ if field.required and fields.get(field.dest) is None]
1162
+ if missing:
1163
+ # @req> REQ-30898926@Vu28eWaG_1mE tljg2g
1164
+ raise ReqctlError(
1165
+ f"a {kind} needs {', '.join(missing)} to be valid the moment it exists "
1166
+ f"-- pass {', '.join(missing)}"
1167
+ )
1168
+ # @req- 4zgzmu
1169
+ # @req+ REQ-25589226@gN1zcZG8pbON nk637k
1170
+ stray = _stray(store.root, own, fields)
1171
+ if stray:
1172
+ # @req> REQ-30898926@Vu28eWaG_1mE m547p4
1173
+ raise ReqctlError(f"{', '.join(stray)} does not apply to a {kind} -- "
1174
+ f"drop {', '.join(stray)}")
1175
+ # @req- nk637k
1176
+ if fields.get("uid") is not None and kind not in ("requirement", "guard"):
1177
+ # @req> REQ-30898926@Vu28eWaG_1mE xhixmr
1178
+ raise ReqctlError(f"--uid does not apply to a {kind}; it is minted "
1179
+ "under the name it carries -- drop --uid")
1180
+ hidden = _blank_or_hidden(fields, own, kind)
1181
+
1182
+ # @req> REQ-41779958@i5ZjNQdrVe4B lxx4he
1183
+ data = _fields.defaults(store.root, kind)
1184
+ entries = next((field for field in own if field.name == "entries"), None)
1185
+ default = {field.name: fields.get(field.dest) for field in own}.get("default")
1186
+ entry = {}
1187
+ for field in own:
1188
+ value = fields.get(field.dest)
1189
+ if value is not None and not _keyed(field, entries):
1190
+ (entry if field.entry else data)[field.name] = _converted(field, value)
1191
+ named = next((data.get(field.name) for field in own if field.address), None)
1192
+ if kind == "term":
1193
+ word = entry.get("word")
1194
+ if not isinstance(word, str):
1195
+ # @req> REQ-30898926@Vu28eWaG_1mE k4sb36
1196
+ raise ReqctlError("a term needs a word: its handle falls out of the "
1197
+ "word it states -- pass --term WORD")
1198
+ handle = corpus.handle_for(word)
1199
+ if not corpus.DATA_KEY.match(handle):
1200
+ # @req> REQ-30898926@Vu28eWaG_1mE ju2fda
1201
+ raise ReqctlError(
1202
+ f"--term {word!r}: no snake_case handle falls out of it, and a "
1203
+ "term is addressed by one -- state a word that holds a letter "
1204
+ "or digit from a to z or 0 to 9")
1205
+ data["entries"] = {handle: entry}
1206
+ named = handle
1207
+ elif kind == "parameter" and entries and fields.get(entries.dest) is not None:
1208
+ data["entries"] = _entered(_one(fields[entries.dest], entries.flag))
1209
+ if default is not None:
1210
+ chosen = entry_key(scalar(str(default)))
1211
+ # @req> REQ-67914848@cRb9rHls6z6c b4izbx
1212
+ if chosen not in data["entries"]:
1213
+ # @req> REQ-30898926@Vu28eWaG_1mE 64xone
1214
+ raise ReqctlError(f"--default {default}: not a member of the set "
1215
+ "-- name one of its members")
1216
+ data["default"] = chosen
1217
+ elif kind == "data" and entries and fields.get(entries.dest) is not None:
1218
+ # @req+ REQ-42544082@VRPSEgM9tJWs 3nahao
1219
+ keys = [str(key).strip() for key in fields[entries.dest]]
1220
+ repeated = sorted({key for key in keys if keys.count(key) > 1})
1221
+ if repeated:
1222
+ # @req> REQ-30898926@Vu28eWaG_1mE qeu3k2
1223
+ raise ReqctlError(
1224
+ f"{entries.flag} {', '.join(repeated)}: given more than once -- "
1225
+ "give each once"
1226
+ )
1227
+ # @req- 3nahao
1228
+ data["entries"] = {key: {} for key in keys}
1229
+ if default is not None:
1230
+ chosen = str(default).strip()
1231
+ # @req> REQ-67914848@cRb9rHls6z6c wzvf5m
1232
+ if chosen not in data["entries"]:
1233
+ # @req> REQ-30898926@Vu28eWaG_1mE h2cq5l
1234
+ raise ReqctlError(f"--default {default}: not an entry -- name one "
1235
+ "of its entries")
1236
+ data["default"] = chosen
1237
+
1238
+ # @req+ REQ-73115701@_3WJxxfhIo7L yorgmf
1239
+ if placeholder is None and fields.get("uid") is not None:
1240
+ placeholder = _placeholder_for(kind, fields["uid"])
1241
+ uid, path = mint(store, kind, named, placeholder)
1242
+ # @req- yorgmf
1243
+ data = _resolved_prose(store, uid, data)
1244
+ # @req+ REQ-85308584@4lLbQIsexIid lmav5l
1245
+ site = path if corpus.UID.match(uid) or named is not None else mint(store, kind)[1]
1246
+ # @req> REQ-42390302@QOC4GhS7SkFg r5sgzy
1247
+ problems = hidden + _new_item_faults(store, [corpus.Item(uid, site, data)])
1248
+ # @req- lmav5l
1249
+ problems += _joined_faults(store, uid, data)
1250
+ return uid, path, data, problems
1251
+
1252
+
1253
+ def _joined_faults(store, uid, prospective):
1254
+ records = {item.uid: item.data for item in corpus.items(store)}
1255
+ paths = corpus.item_files(store.root)
1256
+ # @req+ REQ-85308584@4lLbQIsexIid td4i2r
1257
+ found = _validate.corpus_checks({**records, uid: prospective}, store.root, paths)
1258
+ if all(problem.startswith(f"{uid}: ") for problem in found):
1259
+ return found
1260
+ stood = set(_validate.corpus_checks(records, store.root, paths))
1261
+ return [problem for problem in found
1262
+ if problem not in stood or _validate.UNFINISHED in problem]
1263
+ # @req- td4i2r
1264
+
1265
+
1266
+ def create(store, kind, fields):
1267
+ uid, path, data, problems = prepare(store, kind, fields)
1268
+ if problems:
1269
+ raise ReqctlError("\n".join(problems))
1270
+ item = corpus.Item(uid, path, data)
1271
+ corpus.save(store, item)
1272
+ return item
1273
+
1274
+
1275
+ def _checked(check, *args):
1276
+ try:
1277
+ return check(*args)
1278
+ except ReqctlError:
1279
+ raise
1280
+ except Exception as fault:
1281
+ raise ReqctlError(f"corpus: reqctl could not finish {check.__name__} -- "
1282
+ f"{type(fault).__name__}: {fault}") from fault
1283
+
1284
+
1285
+ def _corpus_faults(store, checked, written, records):
1286
+ return (_validate.coherence(checked, store.root, _checked)
1287
+ + _checked(_validate.shared_quantities, checked, store.root)
1288
+ + _checked(_validate.approved_relations, checked)
1289
+ + _checked(_validate.conflicts, checked)
1290
+ + _checked(_validate.supersedes, checked)
1291
+ + _checked(_validate._bound_guards, checked, store.root)
1292
+ + [fault for uid in sorted(written)
1293
+ for fault in _checked(_validate.unlinked_terms, uid,
1294
+ checked.get(uid) or {}, records, store.root)])
1295
+
1296
+
1297
+ def _new_corpus_faults(store, written):
1298
+ records = {item.uid: item.data for item in corpus.items(store)}
1299
+ found = _corpus_faults(store, {**records, **written}, written, records)
1300
+ if not found:
1301
+ return []
1302
+ stood = _corpus_faults(store, records, written, records)
1303
+ return [problem for problem in found if problem not in stood]
1304
+
1305
+
1306
+ def _pins_only(before, prospective):
1307
+ return all(corpus.same(prospective.get(key), before.get(key))
1308
+ for key in {*before, *prospective} - {"assessed"})
1309
+
1310
+
1311
+ def _new_cycles(store, uid, prospective):
1312
+ records = {item.uid: item.data for item in corpus.items(store)}
1313
+ existing = _validate.cycles(records)
1314
+ return [problem
1315
+ for problem in _validate.cycles({**records, uid: prospective})
1316
+ if problem not in existing]
1317
+
1318
+
1319
+ def _new_item_faults(store, written):
1320
+ # @req+ REQ-42390302@QOC4GhS7SkFg vbwcqp
1321
+ # @req+ REQ-79956352@OsoTGRd6D5CZ m2sskk
1322
+ # @req+ REQ-44791607@6FX5SSp9MG3B norsxb
1323
+ records = {item.uid: item.data for item in corpus.items(store)}
1324
+ joined = {**records, **{item.uid: item.data for item in written}}
1325
+ standing, reach = corpus.reachable(records), corpus.reachable(joined)
1326
+ faults = []
1327
+ for item in written:
1328
+ # @req> REQ-85308584@4lLbQIsexIid sz4ty3
1329
+ found = _validate.item_problems(store.root, item.uid, item.path, item.data,
1330
+ joined, reach, reach, suspect=False)
1331
+ stood = [] if not found or item.uid not in records else _validate.item_problems(
1332
+ store.root, item.uid, item.path, records[item.uid], records, standing,
1333
+ standing, suspect=False)
1334
+ faults += [fault for fault in found if fault not in stood]
1335
+ return faults
1336
+ # @req- norsxb
1337
+ # @req- m2sskk
1338
+ # @req- vbwcqp
1339
+
1340
+
1341
+ # @req> REQ-15211173@2tindbOlAUEJ krbgcj
1342
+ def _placed(uid, blocks, prospective, flag, named):
1343
+ block, dot, key = named.partition(".")
1344
+ if dot and not (block in blocks and key and "." not in key):
1345
+ # @req> REQ-30898926@Vu28eWaG_1mE 5lbsgd
1346
+ raise ReqctlError(f"{flag} {named}: names no block member of {uid} -- name "
1347
+ "it as block.key, where block is one the schema marks "
1348
+ "addressable")
1349
+ place = block if dot else "entries"
1350
+ held = prospective.get(place)
1351
+ if dot and held is not None and not isinstance(held, dict):
1352
+ # @req> REQ-30898926@Vu28eWaG_1mE o3ymh7
1353
+ raise ReqctlError(f"{flag} {named}: {block} of {uid} holds no members "
1354
+ f"under keys -- rewrite {block} as a mapping of its "
1355
+ "members, then retry")
1356
+ prospective[place] = held = dict(held) if isinstance(held, dict) else {}
1357
+ return held, key if dot else named
1358
+
1359
+
1360
+ def revisable_fields(own, kind):
1361
+ return [field for field in own if field.name != "entries"
1362
+ or kind == "parameter" and field.dest not in OPERATIONS]
1363
+
1364
+
1365
+ def _revision(store, uid, fields, together=None):
1366
+ item = corpus.find(store, uid)
1367
+ kind = corpus.kind_of(uid, item.data)
1368
+ if kind not in KINDS:
1369
+ # @req> REQ-30898926@Vu28eWaG_1mE cvizke
1370
+ raise ReqctlError(f"{uid}: names no kind; `reqctl validate` names the fault "
1371
+ f"-- edit {uid}'s file by hand so it states a kind the "
1372
+ "corpus knows")
1373
+ own = _fields.of(store.root, kind)
1374
+ revisable = revisable_fields(own, kind)
1375
+
1376
+ # @req+ REQ-25589226@gN1zcZG8pbON 5apnio
1377
+ stray = _stray(store.root, revisable, fields, OPERATIONS)
1378
+ if stray:
1379
+ # @req> REQ-30898926@Vu28eWaG_1mE betlcx
1380
+ raise ReqctlError(f"{', '.join(stray)} does not apply to a {kind} -- "
1381
+ f"drop {', '.join(stray)}")
1382
+ # @req- 5apnio
1383
+ # @req+ REQ-61755382@LGJAUmpv4utG 7pv66p
1384
+ if fields.get("handle") is not None:
1385
+ if kind != "term":
1386
+ # @req> REQ-30898926@Vu28eWaG_1mE 7cecle
1387
+ raise ReqctlError(
1388
+ f"--handle does not apply to a {kind}; only a term wears its "
1389
+ "word as a key -- drop --handle"
1390
+ )
1391
+ # @req> REQ-30898926@Vu28eWaG_1mE 2uvdy5
1392
+ raise ReqctlError(
1393
+ "--handle: a term's handle is the address the corpus reaches it "
1394
+ f"by; `reqctl rename {uid} HANDLE` moves the file with it"
1395
+ )
1396
+ # @req> REQ-91205530@FiUaykViw6dQ hc64tl
1397
+ for field in own:
1398
+ if field.address and fields.get(field.dest) is not None:
1399
+ noun = "a data item" if kind == "data" else f"a {kind}"
1400
+ # @req> REQ-30898926@Vu28eWaG_1mE e5c7hi
1401
+ raise ReqctlError(
1402
+ f"{field.flag}: {noun}'s {field.name} is the address the corpus "
1403
+ f"reaches it by; `reqctl rename {uid} {field.name.upper()}` "
1404
+ "moves the file with it"
1405
+ )
1406
+ # @req- 7pv66p
1407
+ if fields.get("reword") and kind != "term":
1408
+ # @req> REQ-30898926@Vu28eWaG_1mE lnrgyk
1409
+ raise ReqctlError(
1410
+ f"--reword does not apply to a {kind}; only a term's links show a "
1411
+ "word a statement can outgrow -- drop --reword"
1412
+ )
1413
+
1414
+ if kind in ("term", "parameter") and corpus.entries(item.data) is None:
1415
+ # @req> REQ-30898926@Vu28eWaG_1mE sbazfq
1416
+ raise ReqctlError(f"{uid}: has no entries -- `reqctl validate` names the "
1417
+ f"fault; edit {uid}'s file by hand to give it entries")
1418
+ carried = {said for _, said in _validate._strings(item.data)}
1419
+ hidden = _blank_or_hidden(fields, own, kind, carried)
1420
+ if hidden:
1421
+ raise ReqctlError("\n".join(hidden))
1422
+ if fields.get("entry") is not None and kind not in ("data", "parameter"):
1423
+ # @req> REQ-30898926@Vu28eWaG_1mE r7lubu
1424
+ raise ReqctlError(f"--entry does not apply to a {kind} -- drop --entry")
1425
+ if fields.get("new_entry") is not None and kind != "data":
1426
+ # @req> REQ-30898926@Vu28eWaG_1mE loe7wp
1427
+ raise ReqctlError(f"--new-entry does not apply to a {kind} -- drop "
1428
+ "--new-entry")
1429
+ if fields.get("drop_entry") is not None and kind != "data":
1430
+ # @req> REQ-30898926@Vu28eWaG_1mE mlejhf
1431
+ raise ReqctlError(f"--drop-entry does not apply to a {kind} -- drop "
1432
+ "--drop-entry")
1433
+ if fields.get("entry") is not None and fields.get("new_entry") is not None:
1434
+ # @req> REQ-30898926@Vu28eWaG_1mE olxiz7
1435
+ raise ReqctlError("--entry and --new-entry are one write each; pass one")
1436
+ if (fields.get("set") and fields.get("entry") is None
1437
+ and fields.get("new_entry") is None):
1438
+ # @req> REQ-30898926@Vu28eWaG_1mE ygfwfy
1439
+ raise ReqctlError("--set needs --entry -- name the entry with --entry "
1440
+ "KEY; a data item also takes a new one with --new-entry KEY")
1441
+ if (fields.get("append") and fields.get("entry") is None
1442
+ and fields.get("new_entry") is None):
1443
+ # @req> REQ-30898926@Vu28eWaG_1mE yearku
1444
+ raise ReqctlError("--append needs --entry -- name the entry with "
1445
+ "--entry KEY; a data item also takes a new one with "
1446
+ "--new-entry KEY")
1447
+ if fields.get("unset") and fields.get("entry") is None:
1448
+ # @req> REQ-30898926@Vu28eWaG_1mE oblxrc
1449
+ raise ReqctlError(
1450
+ "--unset needs --entry; a new entry has no field to drop, and "
1451
+ "--drop-entry takes the whole one -- name the entry with --entry KEY"
1452
+ )
1453
+ both = ({str(name).strip() for name in fields.get("unset") or []}
1454
+ & {str(pair).partition("=")[0].strip()
1455
+ for pair in fields.get("set") or []})
1456
+ if both:
1457
+ # @req> REQ-30898926@Vu28eWaG_1mE 6gqwtd
1458
+ raise ReqctlError(
1459
+ f"--unset {', '.join(sorted(both))} contradicts --set; pass one"
1460
+ )
1461
+ for field in own:
1462
+ if (field.clears and fields.get(field.cleared)
1463
+ and fields.get(field.dest) is not None):
1464
+ # @req> REQ-30898926@Vu28eWaG_1mE sqvc5f
1465
+ raise ReqctlError(f"{field.clears} contradicts {field.flag}; pass one")
1466
+ entries = next((field for field in own if field.name == "entries"), None)
1467
+ default = {field.name: fields.get(field.dest) for field in own}.get("default")
1468
+ before = dict(item.data)
1469
+ stated = {field.name: _converted(field, fields[field.dest])
1470
+ for field in own if fields.get(field.dest) is not None
1471
+ and not _keyed(field, entries)}
1472
+ words = _rename_map(before, fields, stated.get("word"))
1473
+ acks = fields.get("ack") or []
1474
+
1475
+ prospective = dict(before)
1476
+ # @req> REQ-34694183@DRvdPekHL7hR i7ubge
1477
+ prospective["assessed"] = _assessed_map(uid, before)
1478
+ if fields.get("kind") is not None:
1479
+ if kind in ("requirement", "guard"):
1480
+ # @req> REQ-30898926@Vu28eWaG_1mE dmhzd4
1481
+ raise ReqctlError(f"--kind does not apply to a {kind} -- drop --kind")
1482
+ if fields["kind"] != kind:
1483
+ # @req> REQ-30898926@Vu28eWaG_1mE ucv7hm
1484
+ raise ReqctlError(
1485
+ f"--kind {fields['kind']}: {uid} is a {kind} -- drop --kind, or "
1486
+ f"mint the item you meant with `reqctl new {fields['kind']}`")
1487
+ prospective["kind"] = kind
1488
+ sole = next(iter(corpus.entries(before) or {}), None)
1489
+ entry = dict(corpus.term_fields(before))
1490
+ for field in own:
1491
+ target = entry if field.entry else prospective
1492
+ # @req> REQ-20206793@HmbOOqLkGGec xna7hd
1493
+ if field.drops and fields.get(field.dropped):
1494
+ recorded = target.pop(field.name, None) or {}
1495
+ if not isinstance(recorded, dict):
1496
+ # @req> REQ-30898926@Vu28eWaG_1mE hvsi7q
1497
+ raise ReqctlError(f"{uid}: {field.name} is not a mapping -- "
1498
+ "`reqctl validate` names the fault; edit "
1499
+ f"{uid}'s file by hand so {field.name} is a "
1500
+ "mapping, or remove it")
1501
+ named = [" ".join(str(key).split()) for key in fields[field.dropped]]
1502
+ absent = [key for key in named if key not in recorded]
1503
+ # @req> REQ-78673239@6YprF1By2TZv hszdda
1504
+ if absent:
1505
+ # @req> REQ-30898926@Vu28eWaG_1mE ketz2v
1506
+ raise ReqctlError(f"{field.drops} {', '.join(map(repr, absent))}: "
1507
+ f"not recorded on {uid} -- name what it records; "
1508
+ f"`reqctl context {uid}` shows it")
1509
+ kept = {key: value for key, value in recorded.items() if key not in named}
1510
+ if kept:
1511
+ target[field.name] = kept
1512
+ if field.name in stated:
1513
+ said = stated[field.name]
1514
+ # @req+ REQ-55564856@yOTVGvtwjD3l gl6li3
1515
+ was = target.get(field.name)
1516
+ if isinstance(said, dict):
1517
+ said = {**{key: value for key, value in (was or {}).items()
1518
+ if " ".join(str(key).split()) not in said}, **said}
1519
+ elif isinstance(said, str) and isinstance(was, str) and said.strip() == was.strip():
1520
+ said = said.strip()
1521
+ target[field.name] = said
1522
+ # @req- gl6li3
1523
+ elif field.clears and fields.get(field.cleared):
1524
+ if field.empty is None:
1525
+ target.pop(field.name, None)
1526
+ else:
1527
+ target[field.name] = field.empty()
1528
+ if any(field.entry for field in own):
1529
+ prospective["entries"] = {sole: entry}
1530
+ if kind == "parameter":
1531
+ if entries in revisable and fields.get(entries.dest) is not None:
1532
+ prospective["entries"] = _entered(
1533
+ _one(fields[entries.dest], entries.flag, carried))
1534
+ held = prospective.pop("default", None)
1535
+ if held in prospective["entries"]:
1536
+ prospective["default"] = held
1537
+ if default is not None:
1538
+ chosen = entry_key(scalar(str(default), carried))
1539
+ # @req> REQ-67914848@cRb9rHls6z6c 6xxjkf
1540
+ if chosen not in corpus.entries(prospective):
1541
+ # @req> REQ-30898926@Vu28eWaG_1mE xghyrm
1542
+ raise ReqctlError(f"--default {default}: not a member of the set "
1543
+ "-- name one of its members")
1544
+ prospective["default"] = chosen
1545
+ if fields.get("entry") is not None:
1546
+ held = dict(corpus.entries(prospective))
1547
+ key = str(fields["entry"]).strip()
1548
+ if key not in held:
1549
+ # @req> REQ-30898926@Vu28eWaG_1mE 4lhyjr
1550
+ raise ReqctlError(f"--entry {key}: not a member of the set -- "
1551
+ "name one of its members")
1552
+ held[key] = _unset_fields(
1553
+ _append_fields(
1554
+ _set_fields(dict(held[key] or {}), fields.get("set") or [], carried),
1555
+ fields.get("append") or [], carried),
1556
+ fields.get("unset") or [], key)
1557
+ prospective["entries"] = held
1558
+ elif kind == "data":
1559
+ # @req+ REQ-11930677@dH7Pu7k4K4YI ra2sb2
1560
+ blocks = corpus.addressable_blocks(store.root, uid, before)
1561
+ if fields.get("new_entry") is not None:
1562
+ named = str(fields["new_entry"]).strip()
1563
+ held, key = _placed(uid, blocks, prospective, "--new-entry", named)
1564
+ if key in held:
1565
+ # @req> REQ-30898926@Vu28eWaG_1mE mvjy5b
1566
+ raise ReqctlError(
1567
+ f"--new-entry {named}: already an entry; use --entry {named}"
1568
+ )
1569
+ # @req+ REQ-31873240@K0ddnolUc2IX mfvnlx
1570
+ verbatim = _verbatim_steps(store, uid, prospective, named, key, fields)
1571
+ held[key] = _append_fields(
1572
+ _set_fields({}, fields.get("set") or [], carried, verbatim),
1573
+ fields.get("append") or [], carried, verbatim)
1574
+ # @req- mfvnlx
1575
+ if fields.get("entry") is not None:
1576
+ named = str(fields["entry"]).strip()
1577
+ held, key = _placed(uid, blocks, prospective, "--entry", named)
1578
+ if key not in held:
1579
+ # @req> REQ-30898926@Vu28eWaG_1mE 3p7tpq
1580
+ raise ReqctlError(
1581
+ f"--entry {named}: not an entry of {uid}; to add one, pass "
1582
+ f"--new-entry {named}"
1583
+ )
1584
+ # @req+ REQ-31873240@K0ddnolUc2IX xp5tyw
1585
+ verbatim = _verbatim_steps(store, uid, prospective, named, key, fields)
1586
+ held[key] = _unset_fields(
1587
+ _append_fields(
1588
+ _set_fields(dict(held[key] or {}), fields.get("set") or [], carried, verbatim),
1589
+ fields.get("append") or [], carried, verbatim),
1590
+ fields.get("unset") or [], named)
1591
+ # @req- xp5tyw
1592
+ if fields.get("drop_entry") is not None:
1593
+ named = str(fields["drop_entry"]).strip()
1594
+ held, key = _placed(uid, blocks, prospective, "--drop-entry", named)
1595
+ if key not in held:
1596
+ # @req> REQ-30898926@Vu28eWaG_1mE dkt6jv
1597
+ raise ReqctlError(f"--drop-entry {named}: no such entry -- name "
1598
+ f"an entry {uid} holds")
1599
+ held.pop(key)
1600
+ # @req- ra2sb2
1601
+ if default is not None:
1602
+ chosen = str(default).strip()
1603
+ # @req> REQ-67914848@cRb9rHls6z6c znrmhw
1604
+ if chosen not in (corpus.entries(prospective) or {}):
1605
+ # @req> REQ-30898926@Vu28eWaG_1mE tukwii
1606
+ raise ReqctlError(f"--default {default}: not an entry -- name one "
1607
+ "of its entries")
1608
+ prospective["default"] = chosen
1609
+
1610
+ targets = _targets(before)
1611
+ prospective = _resolved_prose(store, uid, prospective, targets)
1612
+
1613
+ if (any(field.prose and fields.get(field.dest) is not None for field in own)
1614
+ or fields.get("set") or fields.get("append")):
1615
+ faults = _unknown_references(store, uid, prospective, targets)
1616
+ if faults:
1617
+ raise ReqctlError("\n".join(faults))
1618
+ # @req> REQ-15079061@DGcuZTEeq7ZL s4egdj
1619
+ pinning = not words and _pins_only(before, prospective)
1620
+ # @req+ REQ-53480164@5yFhCLJmuTBt xpj5go
1621
+ # @req+ REQ-62819035@SRb6tL8xEERF qgjwmq
1622
+ # @req> REQ-94401451@EZFY5LjHcj9g bzixh3
1623
+ stranded = [] if pinning else _new_corpus_faults(store, {**(together or {}), uid: prospective})
1624
+ if stranded:
1625
+ raise ReqctlError("\n".join(stranded))
1626
+ # @req- qgjwmq
1627
+ # @req- xpj5go
1628
+
1629
+ referenced = corpus.references(prospective) + corpus.concept_references(prospective)
1630
+ ack_written = {}
1631
+ for ack in acks:
1632
+ written = [address for address in referenced
1633
+ if _addresses_match(store, address, ack)]
1634
+ if not written:
1635
+ # @req> REQ-30898926@Vu28eWaG_1mE khjl2i
1636
+ raise ReqctlError(f"--ack {ack}: the statement does not reference it "
1637
+ "-- name an item the statement links or references")
1638
+ ack_written[ack] = written
1639
+
1640
+ changed = {key: prospective[key] for key in prospective
1641
+ if key != "assessed"
1642
+ and not corpus.same(prospective[key], before.get(key))}
1643
+ for key in [key for key in before
1644
+ if key not in prospective and key != "assessed"]:
1645
+ changed[key] = None
1646
+ wanted = set()
1647
+ lookup = corpus.data_lookup(store)
1648
+ if changed or acks:
1649
+ wanted = _prune_assessed(lookup, uid, prospective)
1650
+ for ack in acks:
1651
+ expanded = set()
1652
+ for written in ack_written[ack]:
1653
+ expanded |= corpus.expand_address(lookup, written)
1654
+ for address in sorted(expanded):
1655
+ target_uid, rest = corpus.split_address(address)
1656
+ pin = corpus.stamp_at(corpus.find(store, target_uid), rest)
1657
+ if pin is None:
1658
+ # @req> REQ-30898926@Vu28eWaG_1mE waymau
1659
+ raise ReqctlError(f"--ack {ack}: {address} names no entry to pin "
1660
+ "-- name an entry the item holds")
1661
+ if prospective["assessed"].get(address) != pin:
1662
+ prospective["assessed"][address] = pin
1663
+ changed.setdefault("assessed", []).append(address)
1664
+ target = corpus.split_address(ack)[0]
1665
+ for address in [held for held in prospective["assessed"]
1666
+ if corpus.split_address(held)[0] == target
1667
+ and held not in wanted]:
1668
+ del prospective["assessed"][address]
1669
+ changed.setdefault("assessed", []).append(address)
1670
+
1671
+ spread = None
1672
+ if words:
1673
+ prospective, spread = _propagate(store, uid, prospective, words)
1674
+ # @req> REQ-42658093@fcEx9NJMNikS gainxj
1675
+ for pair in fields.get("reword") or []:
1676
+ asked = str(pair).partition("=")[0].strip()
1677
+ if asked not in spread["placed"]:
1678
+ # @req> REQ-30898926@Vu28eWaG_1mE tfhoq7
1679
+ raise ReqctlError(
1680
+ f"--reword {pair}: no link shows {asked!r}, so the reword "
1681
+ "placed no word -- name the words a link shows"
1682
+ )
1683
+ # @req> REQ-96926927@JrkNpeQqNUl0 q2ou3q
1684
+ if not changed and not (spread and spread["done"]):
1685
+ asked = [key for key in OPERATIONS
1686
+ + tuple(field.dest for field in own)
1687
+ + tuple(field.cleared for field in own if field.clears)
1688
+ if fields.get(key) is not None]
1689
+ if not asked:
1690
+ # @req> REQ-30898926@Vu28eWaG_1mE 7blfrs
1691
+ raise ReqctlError("nothing to change; pass at least one field")
1692
+ return {}, []
1693
+ if spread:
1694
+ changed["reworded"] = {
1695
+ "links": spread["done"],
1696
+ "items": sorted(held.uid for held in spread["moved"]),
1697
+ "articles": spread["articles"],
1698
+ "left": sorted(spread["left"]),
1699
+ }
1700
+ # @req+ REQ-42390302@QOC4GhS7SkFg vge6ux
1701
+ writing = [corpus.Item(item.uid, item.path, prospective),
1702
+ *(spread["moved"] if spread else [])]
1703
+ faults = _new_item_faults(store, writing)
1704
+ # @req+ REQ-53864992@bIpK1ribjvtb 7a2e6t
1705
+ carried_ways = {fault.partition("\n")[0] for one in writing
1706
+ for fault in _validate.ears(one.uid, corpus.find(store, one.uid).data)}
1707
+ faults = [fault for fault in faults if fault.partition("\n")[0] not in carried_ways]
1708
+ # @req- 7a2e6t
1709
+ if faults:
1710
+ raise ReqctlError("\n".join(faults))
1711
+ # @req- vge6ux
1712
+ return changed, writing
1713
+
1714
+
1715
+ def revise(store, uid, fields):
1716
+ # @req+ REQ-98666936@TeGjonYXb0EM 4k5e4z
1717
+ try:
1718
+ changed, written = _revision(store, uid, fields)
1719
+ except ReqctlError as error:
1720
+ joint = "\n" if "\n" in str(error) else "; "
1721
+ raise ReqctlError(f"{error}{joint}{uid} was not changed") from None
1722
+ # @req- 4k5e4z
1723
+ for held in written:
1724
+ corpus.save(store, held)
1725
+ return changed
1726
+
1727
+
1728
+ # @req> REQ-94401451@EZFY5LjHcj9g sg7dvf
1729
+ def revise_together(store, uids, fields):
1730
+ held = [corpus.find(store, uid) for uid in dict.fromkeys(uids)]
1731
+ named = ", ".join(item.uid for item in held)
1732
+ if fields.get("status") != "approved":
1733
+ # @req> REQ-30898926@Vu28eWaG_1mE ro5ub2
1734
+ raise ReqctlError(f"{named}: several items are revised together only to "
1735
+ "approve them -- pass --status approved, or revise each "
1736
+ "item on its own; none was changed")
1737
+ together = {item.uid: {**item.data, "status": "approved"} for item in held}
1738
+ changes, writing = {}, []
1739
+ for item in held:
1740
+ try:
1741
+ changed, written = _revision(store, item.uid, fields, together)
1742
+ except ReqctlError as error:
1743
+ joint = "\n" if "\n" in str(error) else "; "
1744
+ # @req> REQ-98666936@TeGjonYXb0EM ojozgh
1745
+ raise ReqctlError(f"{error}{joint}none of {named} was changed") from None
1746
+ if set(changed) - {"status"}:
1747
+ # @req> REQ-30898926@Vu28eWaG_1mE kgrdys
1748
+ raise ReqctlError(f"{item.uid}: --status approved is the only change made "
1749
+ f"to several items together -- revise each item on its "
1750
+ f"own for {', '.join(sorted(set(changed) - {'status'}))}; "
1751
+ f"none of {named} was changed")
1752
+ changes[item.uid] = changed
1753
+ writing += written
1754
+ for one in writing:
1755
+ corpus.save(store, one)
1756
+ return changes
1757
+
1758
+
1759
+ def _relations_map(item):
1760
+ kinds = item.data.get("relations")
1761
+ if kinds is None:
1762
+ return {}
1763
+ if not isinstance(kinds, dict):
1764
+ # @req> REQ-30898926@Vu28eWaG_1mE uvsmta
1765
+ raise ReqctlError(
1766
+ f"{item.uid}: relations is not a mapping, so this item's relations "
1767
+ "cannot be changed without discarding it -- `reqctl validate` "
1768
+ f"names the fault; edit {item.uid}'s file by hand so relations maps "
1769
+ "each item to its relation, or remove relations"
1770
+ )
1771
+ return dict(kinds)
1772
+
1773
+
1774
+ def relate(store, source_uid, relation, target_uid):
1775
+ if source_uid == target_uid:
1776
+ # @req> REQ-30898926@Vu28eWaG_1mE fodj5o
1777
+ raise ReqctlError("an item cannot link to itself -- name two different "
1778
+ "items")
1779
+ source = corpus.find(store, source_uid)
1780
+ # @req> REQ-35877465@zMIttKgLLwy- r2xftu
1781
+ target = corpus.find(store, target_uid)
1782
+ stating = corpus.kind_of(source.uid, source.data)
1783
+ if stating not in STATEMENTS:
1784
+ # @req> REQ-30898926@Vu28eWaG_1mE xuui2r
1785
+ raise ReqctlError("only a requirement or a guard carries relations -- "
1786
+ "name a requirement or a guard first")
1787
+ kind = corpus.kind_of(target.uid, target.data)
1788
+ address = corpus.name_of(target.uid, target.data) or target_uid
1789
+ pin = f" -- pin it with `reqctl revise {source_uid} --ack {target_uid}`"
1790
+ # @req> REQ-10503329@rcywmd_10OQY nso2i7
1791
+ if kind in ("parameter", "data"):
1792
+ noun = "a parameter" if kind == "parameter" else "a data item"
1793
+ # @req> REQ-30898926@Vu28eWaG_1mE snowg4
1794
+ raise ReqctlError(
1795
+ f"{noun} is bound by referencing it in the statement, written "
1796
+ "${" + address + "}" + pin
1797
+ )
1798
+ # @req> REQ-35606788@1D-PoBeeDGqr kezsvo
1799
+ if kind == "term":
1800
+ # @req> REQ-30898926@Vu28eWaG_1mE cnwij4
1801
+ raise ReqctlError(
1802
+ "a term is bound by linking it in the statement, written "
1803
+ f"[the words]({address}){pin}"
1804
+ )
1805
+ if kind != stating:
1806
+ # @req> REQ-30898926@Vu28eWaG_1mE 3sf4er
1807
+ raise ReqctlError(f"a {stating} relates only to a {stating} -- name a "
1808
+ f"{stating} as the target")
1809
+
1810
+ kinds = _relations_map(source)
1811
+ repinned = target_uid in kinds
1812
+ # @req> REQ-77808874@o0P_SIB1tzP6 l7oe6k
1813
+ if repinned and kinds[target_uid] != relation:
1814
+ # @req> REQ-30898926@Vu28eWaG_1mE dpxzka
1815
+ raise ReqctlError(
1816
+ f"{source_uid} already records {kinds[target_uid]} toward "
1817
+ f"{target_uid} -- to record {relation} instead, run "
1818
+ f"`reqctl unrelate {source_uid} {target_uid}`, then relate it again"
1819
+ f"\n{source_uid} was not changed")
1820
+ kinds[target_uid] = relation
1821
+ prospective = dict(source.data)
1822
+ prospective["assessed"] = _assessed_map(source_uid, source.data)
1823
+ prospective["relations"] = kinds
1824
+ _prune_assessed(corpus.data_lookup(store), source_uid, prospective)
1825
+ # @req> REQ-15079061@DGcuZTEeq7ZL acmucb
1826
+ pinning = repinned and _pins_only(source.data, prospective)
1827
+ # @req+ REQ-44520277@w92ngdeF87Mm 6sepga
1828
+ closed = [] if pinning else _new_cycles(store, source_uid, prospective)
1829
+ if closed:
1830
+ raise ReqctlError("\n".join(closed) + f"\n{source_uid} was not changed")
1831
+ # @req- 6sepga
1832
+ # @req+ REQ-53480164@5yFhCLJmuTBt ssmh6o
1833
+ stranded = [] if pinning else _new_corpus_faults(store, {source_uid: prospective})
1834
+ if stranded:
1835
+ raise ReqctlError("\n".join(stranded) + f"\n{source_uid} was not changed")
1836
+ # @req- ssmh6o
1837
+ if repinned:
1838
+ prospective["assessed"][target_uid] = corpus.stamp(
1839
+ corpus.find(store, target_uid)
1840
+ )
1841
+ # @req+ REQ-15079061@DGcuZTEeq7ZL gtdcql
1842
+ written = corpus.Item(source_uid, source.path, prospective)
1843
+ faults = _new_item_faults(store, [written]) if pinning else []
1844
+ if faults:
1845
+ raise ReqctlError("\n".join(faults) + f"\n{source_uid} was not changed")
1846
+ # @req- gtdcql
1847
+ corpus.save(store, written)
1848
+ source.data = prospective
1849
+ return repinned
1850
+
1851
+
1852
+ # @req> REQ-59707978@txCcQRCbn81h ipk7bd
1853
+ def _awaiting_pins(item, records, canonical):
1854
+ def held(address):
1855
+ return records.get(canonical.get(address, address))
1856
+
1857
+ pins = corpus.mapping(item.data, "assessed")
1858
+ moved = {}
1859
+ for target in corpus.pin_addresses(held, item.data):
1860
+ target_uid, rest = corpus.split_address(target)
1861
+ stamped = canonical.get(target_uid, target_uid)
1862
+ if stamped not in records:
1863
+ continue
1864
+ stamp = corpus.address_stamp_of(stamped, records[stamped], rest)
1865
+ if stamp is not None and pins.get(target) != stamp:
1866
+ moved[target] = stamp
1867
+ return moved
1868
+
1869
+
1870
+ def repin_links(store, uids, everything):
1871
+ # @req> REQ-27825753@2D8M3t-t1BG1 cyo2du
1872
+ if bool(uids) == bool(everything):
1873
+ # @req> REQ-30898926@Vu28eWaG_1mE f64oxx
1874
+ raise ReqctlError("repin-links names the items to re-pin or takes --all, "
1875
+ "not both and not neither -- run `reqctl repin-links "
1876
+ "UID [UID ...]` or `reqctl repin-links --all`")
1877
+ held = list(corpus.items(store))
1878
+ records = {item.uid: item.data for item in held}
1879
+ canonical = corpus.reachable(records)
1880
+ # @req+ REQ-59707978@txCcQRCbn81h 5usm7z
1881
+ chosen = held if everything else [corpus.find(store, uid) for uid in uids]
1882
+ written, repinned = {}, {}
1883
+ for item in chosen:
1884
+ if item.uid in written or item.data.get("status") in _validate.RETIRED:
1885
+ continue
1886
+ moved = _awaiting_pins(item, records, canonical)
1887
+ # @req> REQ-27564818@6ED2a9SQcZSQ trkw5f
1888
+ if moved:
1889
+ pins = {**_assessed_map(item.uid, item.data), **moved}
1890
+ written[item.uid] = corpus.Item(item.uid, item.path,
1891
+ {**item.data, "assessed": pins})
1892
+ repinned[item.uid] = sorted(moved)
1893
+ # @req- 5usm7z
1894
+ if not written:
1895
+ return repinned
1896
+ # @req> REQ-74985073@u--jP_zHM8wW k3f324
1897
+ faults = _new_corpus_faults(store, {uid: one.data for uid, one in written.items()})
1898
+ # @req+ REQ-82159833@XT-Vyx7SgeW0 u4gac2
1899
+ if faults:
1900
+ raise ReqctlError("\n".join(faults) + "\nno item was written")
1901
+ for one in written.values():
1902
+ corpus.save(store, one)
1903
+ # @req- u4gac2
1904
+ return repinned
1905
+
1906
+
1907
+ def _stated(root):
1908
+ # @req+ REQ-80226740@pd1uDx8y-jrw m6jqza
1909
+ # @req> REQ-21127228@tmZmgoKx792t h75rte
1910
+ ref = _baseline._required_ref(root)
1911
+ named = _baseline.path(root).relative_to(root).as_posix()
1912
+ listed = _baseline._git(root, "ls-tree", ref, "--", named)
1913
+ if listed.returncode:
1914
+ # @req> REQ-30898926@Vu28eWaG_1mE imajoc
1915
+ raise ReqctlError(
1916
+ f"cannot read the tree {ref} names, so whether it states a "
1917
+ f"baseline cannot be told: {listed.stderr.strip()} -- fetch it "
1918
+ "with `git fetch origin`, then run the command again")
1919
+ if not listed.stdout.strip():
1920
+ return set(), None
1921
+ found = _baseline._git(root, "show", f"{ref}:{named}")
1922
+ if found.returncode:
1923
+ # @req> REQ-30898926@Vu28eWaG_1mE fntc33
1924
+ raise ReqctlError(
1925
+ f"{ref} states a baseline that cannot be read: "
1926
+ f"{found.stderr.strip()} -- fetch it again with `git fetch "
1927
+ "origin`, then run the command again")
1928
+ held = corpus.loads(found.stdout, ref)
1929
+ where = f"{named} on {ref.removeprefix('refs/remotes/')}"
1930
+ held = held if isinstance(held, dict) else {}
1931
+ recorded = held.get("items")
1932
+ return (set(recorded) if isinstance(recorded, dict) else set(),
1933
+ f"baseline {held.get('baseline')} in {where}")
1934
+ # @req- m6jqza
1935
+
1936
+
1937
+ def delete(tree, uid):
1938
+ item = corpus.find(tree, uid)
1939
+ status = corpus.raw(item).get("status")
1940
+ # @req> REQ-43821318@iOP6I3zudijr qhlga3
1941
+ if status == "approved":
1942
+ # @req> REQ-30898926@Vu28eWaG_1mE svmcig
1943
+ raise ReqctlError(
1944
+ f"{uid} is approved -- what the corpus promises is deprecated, "
1945
+ f"not removed: `reqctl revise {uid} --status deprecated`"
1946
+ )
1947
+ # @req+ REQ-80226740@pd1uDx8y-jrw d223dk
1948
+ # @req+ REQ-13861816@SpClrm8cjO-v nmlqee
1949
+ name = corpus.name_of(item.uid, item.data)
1950
+ identity = {item.uid} | ({name} if name is not None else set())
1951
+ wore = set(corpus.carried_of(item.uid, item.data)) - {
1952
+ other.uid for other in corpus.items(tree)}
1953
+ stated, cut = _stated(tree.root)
1954
+ if (identity | wore) & stated:
1955
+ # @req> REQ-30898926@Vu28eWaG_1mE vu6cxf
1956
+ raise ReqctlError(
1957
+ f"{uid} is {status}, but {cut} still states it -- it was "
1958
+ "approved when that cut was taken. Delete it once a baseline that "
1959
+ "no longer names it stands there"
1960
+ )
1961
+ # @req- nmlqee
1962
+ # @req- d223dk
1963
+ # @req+ REQ-60430571@YKRlV2VM8y15 ebwpu6
1964
+ holders = sorted(
1965
+ other.uid
1966
+ for other in corpus.items(tree)
1967
+ if other.uid != item.uid
1968
+ and identity & {corpus.split_address(address)[0]
1969
+ for address in (list(corpus.dependencies(other.data))
1970
+ + list(corpus.mapping(other.data,
1971
+ "assessed")))}
1972
+ )
1973
+ if holders:
1974
+ # @req> REQ-30898926@Vu28eWaG_1mE 7toybx
1975
+ raise ReqctlError(
1976
+ f"{', '.join(holders)} still record a link, a reference or a pin "
1977
+ f"toward {uid} -- removing it would leave them pointing at "
1978
+ f"nothing; unlink first: `reqctl unrelate HOLDER {uid}` for a "
1979
+ "relation, `reqctl revise HOLDER` to drop a link or reference"
1980
+ )
1981
+ # @req- ebwpu6
1982
+ # @req+ REQ-42065260@5rvs-ESunP50 s7iyuc
1983
+ corpus.remove(item.path)
1984
+ corpus.invalidate(tree)
1985
+ return item.path
1986
+ # @req- s7iyuc
1987
+
1988
+
1989
+ def unrelate(store, source_uid, target_uid):
1990
+ source = corpus.find(store, source_uid)
1991
+ # @req+ REQ-94696919@Nf39VeBeXq9K fvstef
1992
+ kinds = _relations_map(source)
1993
+ if target_uid not in kinds:
1994
+ corpus.find(store, target_uid)
1995
+ return False
1996
+ kinds.pop(target_uid)
1997
+ # @req- fvstef
1998
+ prospective = dict(source.data)
1999
+ prospective["assessed"] = _assessed_map(source_uid, source.data)
2000
+ prospective["relations"] = kinds
2001
+ _prune_assessed(corpus.data_lookup(store), source_uid, prospective)
2002
+ corpus.save(store, corpus.Item(source_uid, source.path, prospective))
2003
+ source.data = prospective
2004
+ return True