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/__init__.py +0 -0
- reqctl/baseline.py +380 -0
- reqctl/cite.py +1229 -0
- reqctl/cli.py +1514 -0
- reqctl/corpus.py +1283 -0
- reqctl/fields.py +361 -0
- reqctl/graph.py +207 -0
- reqctl/history.py +175 -0
- reqctl/portal.html +2213 -0
- reqctl/portal.py +131 -0
- reqctl/reach.py +425 -0
- reqctl/resolve.py +74 -0
- reqctl/schemas/baseline.schema.yaml +42 -0
- reqctl/schemas/dictionary.schema.yaml +256 -0
- reqctl/schemas/guard.schema.yaml +131 -0
- reqctl/schemas/requirement.schema.yaml +152 -0
- reqctl/settings.py +183 -0
- reqctl/typescript.py +143 -0
- reqctl/uncited.py +257 -0
- reqctl/validate.py +1929 -0
- reqctl/write.py +2004 -0
- reqctl-0.1.38.dist-info/METADATA +11 -0
- reqctl-0.1.38.dist-info/RECORD +26 -0
- reqctl-0.1.38.dist-info/WHEEL +5 -0
- reqctl-0.1.38.dist-info/entry_points.txt +2 -0
- reqctl-0.1.38.dist-info/top_level.txt +1 -0
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
|