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/validate.py
ADDED
|
@@ -0,0 +1,1929 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import datetime
|
|
3
|
+
import functools
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from jsonschema import FormatChecker
|
|
10
|
+
from jsonschema.exceptions import best_match
|
|
11
|
+
|
|
12
|
+
from . import baseline as _baseline
|
|
13
|
+
from . import corpus
|
|
14
|
+
from . import fields as _fields
|
|
15
|
+
from . import history
|
|
16
|
+
from . import settings as _settings
|
|
17
|
+
from . import uncited as _uncited
|
|
18
|
+
|
|
19
|
+
SCHEMAS = ("requirement", "guard", "dictionary", "baseline")
|
|
20
|
+
# @req> REQ-20325022@eJ3lXlumh7tY m2dmcp
|
|
21
|
+
ALIAS_SEPARATOR = ","
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _schemas_are_schemas(root):
|
|
25
|
+
problems, broken = [], set()
|
|
26
|
+
for name in sorted(set(SCHEMAS) | set(corpus.schema_names(root))):
|
|
27
|
+
try:
|
|
28
|
+
corpus.schema(root, name)
|
|
29
|
+
# @req> REQ-14895892@JjrTwHoJqTRe 22v4gm
|
|
30
|
+
except corpus.RefusedCopy:
|
|
31
|
+
raise
|
|
32
|
+
except corpus.ReqctlError as unreadable:
|
|
33
|
+
problems.append(str(unreadable))
|
|
34
|
+
broken.add(name)
|
|
35
|
+
return problems, broken
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
NAMED_FOLDERS = frozenset(
|
|
39
|
+
corpus.FOLDERS[kind] for kind in ("parameter", "term", "data"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _identity(root, path, data):
|
|
43
|
+
uid = path.stem
|
|
44
|
+
problems = []
|
|
45
|
+
kind = corpus.kind_of(uid, data or {})
|
|
46
|
+
if kind in (None, "requirement", "guard"):
|
|
47
|
+
unreadable_named = (data is None and kind is None
|
|
48
|
+
and path.parent.name in NAMED_FOLDERS)
|
|
49
|
+
if not unreadable_named and not corpus.UID.match(uid):
|
|
50
|
+
# @req> REQ-30898926@Vu28eWaG_1mE awdccf
|
|
51
|
+
problems.append(f"{uid}: filename is not PREFIX-NNNNNNNN -- rename "
|
|
52
|
+
"the file by hand to a uid of that form")
|
|
53
|
+
elif not corpus.UID.match(uid) and uid != corpus.name_of(uid, data or {}):
|
|
54
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 5uybod
|
|
55
|
+
problems.append(
|
|
56
|
+
f"{uid}: filename is not the name the item carries -- rename the "
|
|
57
|
+
"file by hand to that name, or give both one name with `reqctl "
|
|
58
|
+
f"rename {uid} NAME`")
|
|
59
|
+
# @req> REQ-48186295@E_K39S9hbhEz yvekz4
|
|
60
|
+
if corpus.UID.match(uid) or kind is not None:
|
|
61
|
+
expected = corpus.folder_for(root, corpus.kind_of(uid, data))
|
|
62
|
+
if path.parent != expected:
|
|
63
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 7vh2hf
|
|
64
|
+
problems.append(
|
|
65
|
+
f"{uid}: file is in {path.parent.name}/, not {expected.name}/ "
|
|
66
|
+
f"-- move it into {expected.name}/ by hand"
|
|
67
|
+
)
|
|
68
|
+
if path.suffix != ".yml":
|
|
69
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 52fz5f
|
|
70
|
+
problems.append(f"{uid}: item file is named {path.name}; items are .yml "
|
|
71
|
+
f"-- rename it by hand to {uid}.yml")
|
|
72
|
+
return problems
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def schema_problems(root, uid, data):
|
|
76
|
+
try:
|
|
77
|
+
declared = corpus.schema_for(root, corpus.kind_of(uid, data),
|
|
78
|
+
corpus.name_of(uid, data))
|
|
79
|
+
# @req> REQ-14895892@JjrTwHoJqTRe r2dbx6
|
|
80
|
+
except corpus.RefusedCopy:
|
|
81
|
+
raise
|
|
82
|
+
except corpus.ReqctlError as absent:
|
|
83
|
+
return [f"{uid}: {absent}"]
|
|
84
|
+
# @req+ REQ-33359142@u8vbxFMjrV6Z p3hzqn
|
|
85
|
+
# @req> REQ-62072712@m81ZZqYdquaS qvzmoq
|
|
86
|
+
problems = []
|
|
87
|
+
for error in sorted(corpus.validator(declared, DATED).iter_errors(data), key=str):
|
|
88
|
+
error = best_match([error])
|
|
89
|
+
where = ".".join(str(p) for p in error.absolute_path) or "(item)"
|
|
90
|
+
said = error.message
|
|
91
|
+
if error.validator == "not" and error.validator_value in ({}, True):
|
|
92
|
+
kind = corpus.kind_of(uid, data)
|
|
93
|
+
said = ("does not apply to a data item" if kind == "data"
|
|
94
|
+
else f"does not apply to a {kind}")
|
|
95
|
+
# @req> REQ-30898926@Vu28eWaG_1mE cgpecz
|
|
96
|
+
problems.append(f"{uid}: schema: {where}: {said} -- correct it "
|
|
97
|
+
"through the flag that writes it, or by hand in "
|
|
98
|
+
f"{uid}'s file where none does")
|
|
99
|
+
# @req- p3hzqn
|
|
100
|
+
# @req> REQ-35443917@I_xqd07AsRI5 znhrrb
|
|
101
|
+
if data.get("verification") == "automated_test" and not any(
|
|
102
|
+
problem.startswith(f"{uid}: schema: verification:")
|
|
103
|
+
for problem in problems):
|
|
104
|
+
# @req> REQ-30898926@Vu28eWaG_1mE pz3h7r
|
|
105
|
+
problems.append(f"{uid}: verification: automated_test is not a "
|
|
106
|
+
"verification method; state inspection, analysis or "
|
|
107
|
+
"demonstration")
|
|
108
|
+
return problems
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ears_forms(noun):
|
|
112
|
+
return (f" The {noun} shall <response>.\n"
|
|
113
|
+
f" When <trigger>, the {noun} shall <response>.\n"
|
|
114
|
+
f" While <state>, the {noun} shall <response>.\n"
|
|
115
|
+
f" If <condition>, then the {noun} shall <response>.\n"
|
|
116
|
+
f" Where <feature>, the {noun} shall <response>.")
|
|
117
|
+
CONDITIONAL = ("When ", "While ", "Where ", "If ")
|
|
118
|
+
OPENERS = CONDITIONAL + ("The ",)
|
|
119
|
+
SHALL = re.compile(r"\bshall\b")
|
|
120
|
+
SUBJECTS = {
|
|
121
|
+
"REQ-": ("product",
|
|
122
|
+
"a requirement governs what the product ships to its users; a rule "
|
|
123
|
+
"about what makes or checks it is a guard"),
|
|
124
|
+
"GUARD-": ("build",
|
|
125
|
+
"a guard governs what makes, checks and governs the product; "
|
|
126
|
+
"a statement about what a user can do is a requirement"),
|
|
127
|
+
}
|
|
128
|
+
SUBJECT = {noun: re.compile(rf"\bthe\s+{noun}\s+shall\b", re.IGNORECASE)
|
|
129
|
+
for noun, _ in SUBJECTS.values()}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ears(uid, data):
|
|
133
|
+
stated = next((v for k, v in SUBJECTS.items() if uid.startswith(k)), None)
|
|
134
|
+
if stated is None:
|
|
135
|
+
return []
|
|
136
|
+
noun, because = stated
|
|
137
|
+
text = " ".join(str(data.get("text") or "").split())
|
|
138
|
+
|
|
139
|
+
faults = []
|
|
140
|
+
# @req+ REQ-18950462@TJVbxYWG6Dcw ey43wr
|
|
141
|
+
obligations = list(SHALL.finditer(text))
|
|
142
|
+
if not obligations:
|
|
143
|
+
faults.append("no 'shall' -- a requirement states an obligation")
|
|
144
|
+
elif len(obligations) > 1:
|
|
145
|
+
faults.append("more than one 'shall' -- one requirement states one "
|
|
146
|
+
"obligation; split the statement")
|
|
147
|
+
elif not re.search(r"\w", text[obligations[0].end():]):
|
|
148
|
+
faults.append("nothing follows 'shall' -- the response is the obligation")
|
|
149
|
+
# @req> REQ-35156546@fPsYxachw_U9 nam7kw
|
|
150
|
+
if obligations and not SUBJECT[noun].search(text):
|
|
151
|
+
faults.append(f'the subject must be "the {noun}" -- {because}')
|
|
152
|
+
if not text.startswith(OPENERS):
|
|
153
|
+
faults.append("must open with When, While, Where, If, or The")
|
|
154
|
+
elif text.startswith(CONDITIONAL):
|
|
155
|
+
opener = next(o for o in CONDITIONAL if text.startswith(o))
|
|
156
|
+
comma = text.find(",")
|
|
157
|
+
if comma == -1:
|
|
158
|
+
faults.append("the precondition must be closed with a comma")
|
|
159
|
+
elif not text[len(opener):comma].strip():
|
|
160
|
+
faults.append(f"the precondition between '{opener.strip()}' and "
|
|
161
|
+
"the comma is empty")
|
|
162
|
+
elif obligations and obligations[0].start() < comma:
|
|
163
|
+
faults.append("'shall' sits in the precondition -- the obligation "
|
|
164
|
+
"follows the comma")
|
|
165
|
+
if text.startswith("If ") and ", then " not in text:
|
|
166
|
+
faults.append("'If ...' must continue ', then the ... shall ...'")
|
|
167
|
+
# @req- ey43wr
|
|
168
|
+
|
|
169
|
+
if not faults:
|
|
170
|
+
return []
|
|
171
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ew3q5h
|
|
172
|
+
return [
|
|
173
|
+
f"{uid}: EARS: {'; '.join(faults)}\n"
|
|
174
|
+
f" statement: {text[:100]}{'...' if len(text) > 100 else ''}\n"
|
|
175
|
+
" restate it with --text in one of these forms:\n"
|
|
176
|
+
f"{ears_forms(noun)}"
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
KEY_SHAPES = {
|
|
181
|
+
"count": re.compile(r"^(0|[1-9][0-9]*)$"),
|
|
182
|
+
"duration": re.compile(r"^(0|[1-9][0-9]*)(\.[0-9]+)?$"),
|
|
183
|
+
"size": re.compile(r"^(0|[1-9][0-9]*)(\.[0-9]+)?$"),
|
|
184
|
+
"ratio": re.compile(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$"),
|
|
185
|
+
"percentage": re.compile(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$"),
|
|
186
|
+
"currency": re.compile(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$"),
|
|
187
|
+
"boolean": re.compile(r"^(true|false)$"),
|
|
188
|
+
"text": re.compile(r"^.+$"),
|
|
189
|
+
}
|
|
190
|
+
UNITLESS = ("count", "ratio", "boolean", "text")
|
|
191
|
+
VALUE_TYPES = "value_types"
|
|
192
|
+
# @req> REQ-74163593@shkbFgp8gpvT iiu22k
|
|
193
|
+
RETIRED = ("superseded", "deprecated")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
NESTING = 100
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _nesting(uid, held):
|
|
200
|
+
# @req> REQ-30898926@Vu28eWaG_1mE r3hsm2
|
|
201
|
+
return [f"{uid}: entry {'.'.join(path)} nests deeper than {NESTING} "
|
|
202
|
+
"levels -- flatten it"
|
|
203
|
+
for path, fields in _entries_within(held) if fields is None]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _entries_within(held, path=(), seen=()):
|
|
207
|
+
if not isinstance(held, dict) or id(held) in seen:
|
|
208
|
+
return
|
|
209
|
+
if len(seen) >= NESTING:
|
|
210
|
+
yield path, None
|
|
211
|
+
return
|
|
212
|
+
seen = seen + (id(held),)
|
|
213
|
+
for key, fields in held.items():
|
|
214
|
+
if isinstance(fields, dict):
|
|
215
|
+
where = path + (str(key),)
|
|
216
|
+
yield where, fields
|
|
217
|
+
for name, value in fields.items():
|
|
218
|
+
yield from _entries_within(value, where + (str(name),), seen)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _written(value):
|
|
222
|
+
if isinstance(value, bool):
|
|
223
|
+
return str(value).lower()
|
|
224
|
+
if isinstance(value, float):
|
|
225
|
+
return format(value, "f")
|
|
226
|
+
return str(value) if isinstance(value, (int, str)) else ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _typed(where, value_type, stated, unit):
|
|
230
|
+
problems = []
|
|
231
|
+
shape = KEY_SHAPES.get(value_type)
|
|
232
|
+
if shape is None and isinstance(value_type, str):
|
|
233
|
+
# @req> REQ-30898926@Vu28eWaG_1mE sza2az
|
|
234
|
+
problems.append(f"{where}: value_type {value_type} is not a type "
|
|
235
|
+
"reqctl reads an entry key as -- state one with "
|
|
236
|
+
"--value-type")
|
|
237
|
+
if shape is not None:
|
|
238
|
+
# @req> REQ-41697188@IRR8A-_NbYw4 fv4qpq
|
|
239
|
+
for named, written in stated:
|
|
240
|
+
if not shape.match(written):
|
|
241
|
+
# @req> REQ-30898926@Vu28eWaG_1mE mhsloy
|
|
242
|
+
problems.append(f"{where}: {named} does not parse as "
|
|
243
|
+
f"{value_type} -- write it as a {value_type}, "
|
|
244
|
+
"or change value_type to the type it is")
|
|
245
|
+
# @req+ REQ-57061306@grhOhf7rpAJV gf75aj
|
|
246
|
+
if value_type in UNITLESS and unit is not None:
|
|
247
|
+
# @req> REQ-30898926@Vu28eWaG_1mE gwseiw
|
|
248
|
+
problems.append(f"{where}: unit does not apply to {value_type} -- "
|
|
249
|
+
"drop it with --no-unit on a parameter, or --unset "
|
|
250
|
+
"unit on a data entry")
|
|
251
|
+
if (value_type is not None and value_type not in UNITLESS
|
|
252
|
+
and unit is None):
|
|
253
|
+
# @req> REQ-30898926@Vu28eWaG_1mE o447gk
|
|
254
|
+
problems.append(f"{where}: {value_type} carries a unit -- state it "
|
|
255
|
+
"with --unit on a parameter, or --set unit=UNIT on a "
|
|
256
|
+
"data entry")
|
|
257
|
+
# @req- gf75aj
|
|
258
|
+
return problems
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def dictionary_rules(uid, data):
|
|
262
|
+
problems = []
|
|
263
|
+
item_kind = corpus.kind_of(uid, data)
|
|
264
|
+
declared = data.get("kind")
|
|
265
|
+
if declared is not None and declared != item_kind:
|
|
266
|
+
# @req> REQ-30898926@Vu28eWaG_1mE wvfq3z
|
|
267
|
+
problems.append(
|
|
268
|
+
f"{uid}: states kind {declared} -- state {item_kind} or mint the "
|
|
269
|
+
f"item you meant")
|
|
270
|
+
held = corpus.entries(data)
|
|
271
|
+
if held is None or uid.startswith("REQ-"):
|
|
272
|
+
return problems
|
|
273
|
+
# @req> REQ-67450031@dHzdY0EyKyJ3 5qg2a5
|
|
274
|
+
for key in ("default", "pinned", "text", "name"):
|
|
275
|
+
if key in held and (item_kind, corpus.name_of(uid, data)) != ("data", VALUE_TYPES):
|
|
276
|
+
# @req> REQ-30898926@Vu28eWaG_1mE iuktgn
|
|
277
|
+
problems.append(f"{uid}: an entry may not be keyed {key} -- it "
|
|
278
|
+
"would shadow an item field of that name; rename "
|
|
279
|
+
"the entry")
|
|
280
|
+
# @req> REQ-22887335@Tt3HpjzWuiU6 idfqw5
|
|
281
|
+
if corpus.MEMBERS in held:
|
|
282
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 35cjyj
|
|
283
|
+
problems.append(f"{uid}: an entry may not be keyed {corpus.MEMBERS} -- "
|
|
284
|
+
"that address pins the item's membership; rename the "
|
|
285
|
+
"entry")
|
|
286
|
+
problems += _nesting(uid, held)
|
|
287
|
+
if item_kind == "term":
|
|
288
|
+
# @req> REQ-79013939@LHWesLk8a6d7 wcl7vt
|
|
289
|
+
if len(held) != 1:
|
|
290
|
+
# @req> REQ-30898926@Vu28eWaG_1mE hckx4k
|
|
291
|
+
problems.append(f"{uid}: a term holds exactly one entry, its word "
|
|
292
|
+
f"-- edit {uid}'s file by hand so it holds that "
|
|
293
|
+
"one entry")
|
|
294
|
+
# @req+ REQ-47317077@nmlbp2InoWXJ iwiit6
|
|
295
|
+
stated = corpus.term_fields(data).get("word")
|
|
296
|
+
if not (isinstance(stated, str) and stated.strip()):
|
|
297
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 3kpufa
|
|
298
|
+
problems.append(f"{uid}: word is {stated!r} -- a term states the "
|
|
299
|
+
"word it is known by, as text; the entry key is a "
|
|
300
|
+
"handle and addresses it -- state the word with "
|
|
301
|
+
"--term")
|
|
302
|
+
# @req- iwiit6
|
|
303
|
+
# @req+ REQ-20325022@eJ3lXlumh7tY smwuev
|
|
304
|
+
aliases = corpus.term_fields(data).get("aliases")
|
|
305
|
+
for alias in aliases if isinstance(aliases, list) else []:
|
|
306
|
+
if ALIAS_SEPARATOR in str(alias):
|
|
307
|
+
# @req> REQ-30898926@Vu28eWaG_1mE y3z2hn
|
|
308
|
+
problems.append(
|
|
309
|
+
f"{uid}: alias {alias!r} holds {ALIAS_SEPARATOR!r}, which "
|
|
310
|
+
"separates an alias list -- give each wording an alias of "
|
|
311
|
+
"its own")
|
|
312
|
+
# @req- smwuev
|
|
313
|
+
# @req> REQ-15469759@ETBwvNW6rbpC b6qizb
|
|
314
|
+
for phrase, reason in corpus.unclaimed(data).items():
|
|
315
|
+
if not reason.strip():
|
|
316
|
+
# @req> REQ-30898926@Vu28eWaG_1mE coceck
|
|
317
|
+
problems.append(
|
|
318
|
+
f"{uid}: records \"{phrase}\" as not the term but gives no "
|
|
319
|
+
f"reason -- write --unclaimed \"{phrase}=the reason\"")
|
|
320
|
+
# @req+ REQ-23885960@rwNen-FIWBND 5n63fi
|
|
321
|
+
words = [_worded(word) for word in _term_words(data)]
|
|
322
|
+
for phrase in corpus.unclaimed(data):
|
|
323
|
+
if not any(word.search(phrase) for word in words):
|
|
324
|
+
# @req> REQ-30898926@Vu28eWaG_1mE gnjmvl
|
|
325
|
+
problems.append(
|
|
326
|
+
f"{uid}: records \"{phrase}\" as not the term, but it holds "
|
|
327
|
+
"neither the word nor an alias -- record a phrase that holds "
|
|
328
|
+
f"one, or drop it with --drop-unclaimed \"{phrase}\"")
|
|
329
|
+
# @req- 5n63fi
|
|
330
|
+
return problems
|
|
331
|
+
# @req+ REQ-67914848@cRb9rHls6z6c fnsqos
|
|
332
|
+
default = data.get("default")
|
|
333
|
+
if default is not None and default not in held:
|
|
334
|
+
# @req> REQ-30898926@Vu28eWaG_1mE weapt5
|
|
335
|
+
problems.append(f"{uid}: default names {default!r}, which is not an "
|
|
336
|
+
"entry -- name an entry with --default, or clear it "
|
|
337
|
+
"with --no-default")
|
|
338
|
+
# @req- fnsqos
|
|
339
|
+
if item_kind == "data":
|
|
340
|
+
for key in held:
|
|
341
|
+
if not corpus.DATA_KEY.match(str(key)):
|
|
342
|
+
# @req> REQ-30898926@Vu28eWaG_1mE qgxblp
|
|
343
|
+
problems.append(f"{uid}: entry key {key!r} is not a snake_case "
|
|
344
|
+
"handle -- add the entry again under a "
|
|
345
|
+
"snake_case key with --new-entry and remove "
|
|
346
|
+
"this one with --drop-entry, or rekey it by hand")
|
|
347
|
+
return problems
|
|
348
|
+
return problems + _typed(
|
|
349
|
+
uid, data.get("value_type"),
|
|
350
|
+
[(f"entry key {key!r}", key if isinstance(key, str) else "")
|
|
351
|
+
for key in held],
|
|
352
|
+
data.get("unit"))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def text_values(uid, data):
|
|
356
|
+
if corpus.kind_of(uid, data) != "parameter" or data.get("value_type") != "text":
|
|
357
|
+
return []
|
|
358
|
+
problems = []
|
|
359
|
+
# @req> REQ-83091747@onYntcqXXhk7 q5fj2f
|
|
360
|
+
for member in corpus.entries(data) or {}:
|
|
361
|
+
if not isinstance(member, str):
|
|
362
|
+
continue
|
|
363
|
+
found = QUANTITY.fullmatch(re.sub(r"\A[+-](?=\.?\d)", "", member.strip()))
|
|
364
|
+
if found and not isinstance(_quantity(found.group(1)), str):
|
|
365
|
+
# @req> REQ-30898926@Vu28eWaG_1mE btxjsu
|
|
366
|
+
problems.append(
|
|
367
|
+
f"{uid}: text value {member!r} is shaped like a quantity -- "
|
|
368
|
+
"give it a numeric value_type, or reword the name"
|
|
369
|
+
)
|
|
370
|
+
# @req> REQ-40026456@tmXkAn-osOnn 6qstia
|
|
371
|
+
if len(corpus.entries(data) or {}) > 1:
|
|
372
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 23iaaz
|
|
373
|
+
problems.append(f"{uid}: a parameter of text holding more than one "
|
|
374
|
+
f"member is a data item -- `reqctl refile {uid}`")
|
|
375
|
+
return problems
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# @req> REQ-98653444@_BYrN0hH1mzV gvf6gm
|
|
379
|
+
def repeated_labels(uid, data):
|
|
380
|
+
criteria = data.get("acceptance_criteria")
|
|
381
|
+
if not isinstance(criteria, list):
|
|
382
|
+
return []
|
|
383
|
+
problems = []
|
|
384
|
+
for at, criterion in enumerate(criteria, 1):
|
|
385
|
+
for part, said in (criterion.items() if isinstance(criterion, dict) else ()):
|
|
386
|
+
found = (re.match(rf"{re.escape(part)}\b",
|
|
387
|
+
"".join(filter(str.isprintable,
|
|
388
|
+
re.sub(r"\s", " ", said))).lstrip(),
|
|
389
|
+
re.IGNORECASE)
|
|
390
|
+
if isinstance(part, str) and isinstance(said, str) else None)
|
|
391
|
+
if found:
|
|
392
|
+
# @req> REQ-30898926@Vu28eWaG_1mE jpevge
|
|
393
|
+
problems.append(f"{uid}: criterion {at}'s {part} {said!r} begins "
|
|
394
|
+
f"with {found.group()!r}, which reqctl writes "
|
|
395
|
+
f"before it -- drop the word from the {part}")
|
|
396
|
+
return problems
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def hidden(text):
|
|
400
|
+
return next((c for i, c in enumerate(text)
|
|
401
|
+
if not c.isprintable() or (i == 0 and unicodedata.category(c) == "Mn")),
|
|
402
|
+
None)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def hidden_fault(char):
|
|
406
|
+
if len(f"a{char}b".splitlines()) > 1:
|
|
407
|
+
return f"holds U+{ord(char):04X}, a line break -- write it as one line"
|
|
408
|
+
if char.isspace():
|
|
409
|
+
return (f"holds U+{ord(char):04X}, which prints as a plain space but "
|
|
410
|
+
"is not one -- respace it with ordinary spaces")
|
|
411
|
+
return (f"holds U+{ord(char):04X}, which prints as nothing -- retype it "
|
|
412
|
+
"rather than pasting it")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _strings(node, at=()):
|
|
416
|
+
if isinstance(node, set):
|
|
417
|
+
node = dict.fromkeys(node)
|
|
418
|
+
if isinstance(node, dict):
|
|
419
|
+
for key, value in node.items():
|
|
420
|
+
if isinstance(key, str):
|
|
421
|
+
yield at, key
|
|
422
|
+
yield from _strings(value, at + (str(key),))
|
|
423
|
+
elif isinstance(node, (list, tuple)):
|
|
424
|
+
for index, value in enumerate(node):
|
|
425
|
+
yield from _strings(value, at + (str(index),))
|
|
426
|
+
elif isinstance(node, str):
|
|
427
|
+
yield at, node
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# @req> REQ-22682482@F_OIMxlkifKy ijkpsx
|
|
431
|
+
def listed_fields(root, uid, data):
|
|
432
|
+
if corpus.kind_of(uid, data) != "data":
|
|
433
|
+
return []
|
|
434
|
+
name = corpus.name_of(uid, data)
|
|
435
|
+
schema = corpus.schema_for(root, "data", name)
|
|
436
|
+
own = corpus.schema_name_at(root, "data", name) != corpus.schema_name_for("data")
|
|
437
|
+
held = _fields._merged(schema, "data")[0].get("entries", {})
|
|
438
|
+
listed = data.get("fields") if isinstance(data.get("fields"), list) else []
|
|
439
|
+
problems = []
|
|
440
|
+
for key, entry in sorted((corpus.entries(data) or {}).items(),
|
|
441
|
+
key=lambda item: str(item[0])):
|
|
442
|
+
if not isinstance(entry, dict):
|
|
443
|
+
continue
|
|
444
|
+
applied = _fields._resolved(schema, corpus.mapping(held, "properties").get(
|
|
445
|
+
str(key), held.get("additionalProperties")))
|
|
446
|
+
others = applied.get("additionalProperties")
|
|
447
|
+
if own and (others is False or isinstance(others, dict)):
|
|
448
|
+
continue
|
|
449
|
+
# @req> REQ-30898926@Vu28eWaG_1mE yee54k
|
|
450
|
+
problems += [f"{uid}: entry {key} holds the field {field}, which its schema does "
|
|
451
|
+
f"not declare and the data item does not list -- it lists "
|
|
452
|
+
f"{', '.join(map(str, listed)) or 'no fields'}; correct the field, "
|
|
453
|
+
"or add it to fields"
|
|
454
|
+
for field in sorted(map(str, entry))
|
|
455
|
+
if field not in corpus.mapping(applied, "properties")
|
|
456
|
+
and field not in listed]
|
|
457
|
+
return problems
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# @req> REQ-20342497@XOTP8seel4tN sm5nxf
|
|
461
|
+
def _shadowed_blocks(records, root):
|
|
462
|
+
# @req> REQ-30898926@Vu28eWaG_1mE azzv2t
|
|
463
|
+
return [f"{uid}: an entry may not be keyed {key} -- the schema marks {key} as "
|
|
464
|
+
"an addressable block, so an address naming it would name both; "
|
|
465
|
+
"rename the entry"
|
|
466
|
+
for uid, data in sorted(records.items())
|
|
467
|
+
for blocks in [corpus.addressable_blocks(root, uid, data)]
|
|
468
|
+
for key in sorted(map(str, corpus.entries(data) or {})) if key in blocks]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# @req> REQ-79956352@OsoTGRd6D5CZ 5uqc7i
|
|
472
|
+
def hidden_values(uid, data):
|
|
473
|
+
problems = []
|
|
474
|
+
for at, said in _strings(data):
|
|
475
|
+
char = hidden(said)
|
|
476
|
+
if char is not None:
|
|
477
|
+
# @req> REQ-30898926@Vu28eWaG_1mE gpeu6h
|
|
478
|
+
problems.append(f"{uid}: {'.'.join(at) or '(item)'} {said!r} "
|
|
479
|
+
f"{hidden_fault(char)}")
|
|
480
|
+
return problems
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _relations(uid, data, records, canonical, known):
|
|
484
|
+
stated = data.get("relations")
|
|
485
|
+
# @req> REQ-30898926@Vu28eWaG_1mE cyqvl3
|
|
486
|
+
problems = [
|
|
487
|
+
f"{uid}: relates {target!r}, which is not a {corpus.NAMED} uid -- "
|
|
488
|
+
f"remove it from {uid}'s relations by hand"
|
|
489
|
+
for target in sorted(stated if isinstance(stated, dict) else {}, key=str)
|
|
490
|
+
if not isinstance(target, str)
|
|
491
|
+
]
|
|
492
|
+
requirement = uid.startswith("REQ-")
|
|
493
|
+
for target in sorted(corpus.mapping(data, "relations")):
|
|
494
|
+
# @req> REQ-26911169@Ty70WfMx9YfH p2wcdn
|
|
495
|
+
if target == uid:
|
|
496
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 5usqw3
|
|
497
|
+
problems.append(f"{uid}: links to itself -- `reqctl unrelate "
|
|
498
|
+
f"{uid} {uid}`")
|
|
499
|
+
continue
|
|
500
|
+
if requirement:
|
|
501
|
+
held = canonical.get(target)
|
|
502
|
+
kind = (corpus.kind_of(held, records[held]) if held in records
|
|
503
|
+
else corpus.kind_for_uid(target))
|
|
504
|
+
if kind in ("parameter", "data"):
|
|
505
|
+
noun = "a parameter" if kind == "parameter" else "a data item"
|
|
506
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 3bxk3t
|
|
507
|
+
problems.append(
|
|
508
|
+
f"{uid}: relates {target}; {noun} is bound by referencing "
|
|
509
|
+
"it in the statement, not by a relation -- `reqctl "
|
|
510
|
+
f"unrelate {uid} {target}`"
|
|
511
|
+
)
|
|
512
|
+
continue
|
|
513
|
+
if kind == "term":
|
|
514
|
+
# @req> REQ-30898926@Vu28eWaG_1mE iyunoa
|
|
515
|
+
problems.append(
|
|
516
|
+
f"{uid}: relates {target}; a term is bound by linking it in "
|
|
517
|
+
"the statement, written [the words](" + target + ") -- "
|
|
518
|
+
f"`reqctl unrelate {uid} {target}`"
|
|
519
|
+
)
|
|
520
|
+
continue
|
|
521
|
+
# @req> REQ-35877465@zMIttKgLLwy- cx6r6x
|
|
522
|
+
if target not in known:
|
|
523
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 22tyzi
|
|
524
|
+
problems.append(f"{uid}: links to {target}, which does not exist "
|
|
525
|
+
f"-- `reqctl unrelate {uid} {target}`, or mint "
|
|
526
|
+
f"{target} with `reqctl new`")
|
|
527
|
+
return problems
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _references(uid, data, known, root):
|
|
531
|
+
problems = []
|
|
532
|
+
# @req+ REQ-44791607@6FX5SSp9MG3B e5vzf6
|
|
533
|
+
for address in corpus.references(data):
|
|
534
|
+
target, _ = corpus.split_address(address)
|
|
535
|
+
if target not in known:
|
|
536
|
+
# @req> REQ-30898926@Vu28eWaG_1mE wpeyzk
|
|
537
|
+
problems.append(f"{uid}: references {target}, which does not "
|
|
538
|
+
f"exist -- mint {target} with `reqctl new`, or "
|
|
539
|
+
f"reword {uid} without the reference")
|
|
540
|
+
for near in corpus.PARAM_NEAR.finditer(corpus.prose(data)):
|
|
541
|
+
token = near.group()
|
|
542
|
+
if not corpus.PARAM_REF.fullmatch(token):
|
|
543
|
+
# @req> REQ-30898926@Vu28eWaG_1mE vin7kb
|
|
544
|
+
problems.append(
|
|
545
|
+
f"{uid}: text contains {token}, which is not a parameter "
|
|
546
|
+
f"reference -- the form is {corpus.NAME_FORM}"
|
|
547
|
+
)
|
|
548
|
+
for address in corpus.concept_references(data):
|
|
549
|
+
target, _ = corpus.split_address(address)
|
|
550
|
+
if target not in known:
|
|
551
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 7dubyp
|
|
552
|
+
problems.append(f"{uid}: links to {target}, which does not exist "
|
|
553
|
+
f"-- mint {target} with `reqctl new`, or reword "
|
|
554
|
+
f"{uid} without the link")
|
|
555
|
+
# @req- e5vzf6
|
|
556
|
+
for near in corpus.CONCEPT_NEAR.finditer(
|
|
557
|
+
corpus.CONCEPT_LINK.sub(" ", corpus.prose(data))):
|
|
558
|
+
# @req> REQ-30898926@Vu28eWaG_1mE b35ssj
|
|
559
|
+
problems.append(
|
|
560
|
+
f"{uid}: {' '.join(near.group(1).split())} does not read as a "
|
|
561
|
+
"link -- an item is addressed by the name it carries, the words "
|
|
562
|
+
"and the address each sit on one line, a term is linked whole"
|
|
563
|
+
)
|
|
564
|
+
# @req> REQ-48500355@rnstkLKM48vN f7binw
|
|
565
|
+
for near in corpus.TERM_NEAR.finditer(
|
|
566
|
+
corpus.unlinked_prose(own_words(root, uid, data))):
|
|
567
|
+
# @req> REQ-30898926@Vu28eWaG_1mE j74ncb
|
|
568
|
+
problems.append(
|
|
569
|
+
f"{uid}: text contains {near.group()} outside a link -- a term is "
|
|
570
|
+
f"written {corpus.LINK_FORM}"
|
|
571
|
+
)
|
|
572
|
+
return problems
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _term_words(data):
|
|
576
|
+
fields = corpus.term_fields(data)
|
|
577
|
+
aliases = fields.get("aliases")
|
|
578
|
+
words = [corpus.term_word(data),
|
|
579
|
+
*(aliases if isinstance(aliases, list) else [])]
|
|
580
|
+
return [word.strip() for word in words
|
|
581
|
+
if isinstance(word, str) and word.strip()]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _worded(text):
|
|
585
|
+
return re.compile(r"(?<![\w-])" + r"\s+".join(map(re.escape, text.split()))
|
|
586
|
+
+ r"(?![\w-])", re.IGNORECASE)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def term_index(records):
|
|
590
|
+
return [
|
|
591
|
+
(uid, word, pattern,
|
|
592
|
+
[(_worded(phrase), reason)
|
|
593
|
+
for phrase, reason in corpus.unclaimed(data).items()
|
|
594
|
+
if pattern.search(phrase)])
|
|
595
|
+
for uid, data in sorted(records.items())
|
|
596
|
+
if corpus.kind_of(uid, data) == "term"
|
|
597
|
+
for word in _term_words(data)
|
|
598
|
+
for pattern in [re.compile(r"(?<![\w-])" + re.escape(word) + r"(?![\w-])",
|
|
599
|
+
re.IGNORECASE)]
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _outside(text, phrases):
|
|
604
|
+
for phrase, _ in phrases:
|
|
605
|
+
text = phrase.sub(" ", text)
|
|
606
|
+
return text
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _bare_terms(uid, data, defined):
|
|
610
|
+
# @req+ REQ-83702299@kBGzIwAyKDHL tmczgb
|
|
611
|
+
bare = corpus.unlinked_prose(data)
|
|
612
|
+
# @req> REQ-24481048@ZhiYUpKwnPlA bapzey
|
|
613
|
+
# @req> REQ-30898926@Vu28eWaG_1mE db25wj
|
|
614
|
+
return [
|
|
615
|
+
f"{uid}: uses \"{word}\" without linking it -- write "
|
|
616
|
+
f"[{word}]({term_uid}), or reword so the statement does not "
|
|
617
|
+
"claim the term"
|
|
618
|
+
for term_uid, word, pattern, phrases in defined
|
|
619
|
+
if term_uid != uid and pattern.search(_outside(bare, phrases))
|
|
620
|
+
]
|
|
621
|
+
# @req- tmczgb
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def exempted(records, root, exempt):
|
|
625
|
+
prose = {uid: corpus.unlinked_prose(own_words(root, uid, data))
|
|
626
|
+
for uid, data in records.items()}
|
|
627
|
+
# @req> REQ-88221320@vIuzLHP0hHUo 5gpgix
|
|
628
|
+
# @req> REQ-46373547@54MxhOaz3t8O 3lh7ij
|
|
629
|
+
exempt += sorted(
|
|
630
|
+
f"{uid}: \"{' '.join(found.group().split())}\" accepted unlinked, a phrase recorded on term {term_uid} -- {reason}"
|
|
631
|
+
for term_uid, _, _, phrases in term_index(_approved(records))
|
|
632
|
+
for uid, text in prose.items() if uid != term_uid
|
|
633
|
+
for phrase, reason in phrases
|
|
634
|
+
for found in phrase.finditer(text)
|
|
635
|
+
)
|
|
636
|
+
return []
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def unlinked_terms(uid, data, records, root):
|
|
640
|
+
return _bare_terms(uid, own_words(root, uid, data),
|
|
641
|
+
term_index(_approved(records)))
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _unlinked_terms(records, root):
|
|
645
|
+
defined = term_index(_approved(records))
|
|
646
|
+
return [problem
|
|
647
|
+
for uid, data in sorted(records.items())
|
|
648
|
+
for problem in _bare_terms(uid, own_words(root, uid, data), defined)]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
QUANTITY = re.compile(
|
|
652
|
+
r"(?<!\w)(?<!\d\.)(?<![^\W\d_]-)"
|
|
653
|
+
r"(\.?\d[\d,_]*(?:\.\d+)*(?:[eE][+-]?\d+)?)(?:(?!\n)[\s-])*"
|
|
654
|
+
r"((?:[^\W\d_]|[%\u00b0])*)"
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
ISO_UNITS = {
|
|
658
|
+
"s": "s", "sec": "s", "secs": "s", "second": "s", "seconds": "s",
|
|
659
|
+
"ms": "ms", "msec": "ms", "msecs": "ms",
|
|
660
|
+
"millisecond": "ms", "milliseconds": "ms",
|
|
661
|
+
"us": "\u00b5s", "\u00b5s": "\u00b5s",
|
|
662
|
+
"microsecond": "\u00b5s", "microseconds": "\u00b5s",
|
|
663
|
+
"ns": "ns", "nanosecond": "ns", "nanoseconds": "ns",
|
|
664
|
+
"min": "min", "mins": "min", "minute": "min", "minutes": "min",
|
|
665
|
+
"h": "h", "hr": "h", "hrs": "h", "hour": "h", "hours": "h",
|
|
666
|
+
"d": "d", "day": "d", "days": "d",
|
|
667
|
+
"bit": "bit", "bits": "bit",
|
|
668
|
+
"b": "B", "byte": "B", "bytes": "B",
|
|
669
|
+
"kb": "kB", "kilobyte": "kB", "kilobytes": "kB",
|
|
670
|
+
"mb": "MB", "megabyte": "MB", "megabytes": "MB",
|
|
671
|
+
"gb": "GB", "gigabyte": "GB", "gigabytes": "GB",
|
|
672
|
+
"tb": "TB", "terabyte": "TB", "terabytes": "TB",
|
|
673
|
+
"kib": "KiB", "kibibyte": "KiB", "kibibytes": "KiB",
|
|
674
|
+
"mib": "MiB", "mebibyte": "MiB", "mebibytes": "MiB",
|
|
675
|
+
"gib": "GiB", "gibibyte": "GiB", "gibibytes": "GiB",
|
|
676
|
+
"%": "%", "percent": "%", "pct": "%",
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
HYPHENATED = re.compile(r"(?<![\w.-])[^\W\d_][\w.]*(?:-[\w.]*)+")
|
|
680
|
+
|
|
681
|
+
RADIX = "."
|
|
682
|
+
GROUPED_DIGITS = {"Zs", "Pc", "Pi", "Pf", "Po"}
|
|
683
|
+
SPLIT_DIGITS = re.compile(r"(?<=\d)(\D)(?=\d{3}(?!\d))")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _ungrouped(text):
|
|
687
|
+
def joined(found):
|
|
688
|
+
mark = found.group(1)
|
|
689
|
+
if mark != RADIX and unicodedata.category(mark) in GROUPED_DIGITS:
|
|
690
|
+
return ""
|
|
691
|
+
return mark
|
|
692
|
+
return SPLIT_DIGITS.sub(joined, text)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _quantity(written):
|
|
696
|
+
text = str(written)
|
|
697
|
+
plain = text.replace(",", "").replace("_", "")
|
|
698
|
+
try:
|
|
699
|
+
return int(plain)
|
|
700
|
+
except ValueError:
|
|
701
|
+
pass
|
|
702
|
+
try:
|
|
703
|
+
number = float(plain)
|
|
704
|
+
except ValueError:
|
|
705
|
+
return text
|
|
706
|
+
if not math.isfinite(number):
|
|
707
|
+
return number
|
|
708
|
+
return int(number) if number.is_integer() else number
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def quantities(text, units=ISO_UNITS):
|
|
712
|
+
held = _ungrouped(HYPHENATED.sub(" ", corpus.UID_IN_PROSE.sub(" ", text)))
|
|
713
|
+
return {(_quantity(found.group(1)), _unit(found.group(2), units))
|
|
714
|
+
for found in QUANTITY.finditer(held)}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _quantities(text, units=ISO_UNITS):
|
|
718
|
+
return quantities(corpus.PARAM_REF.sub(" ", corpus.CONCEPT_LINK.sub(r"\1", text)), units)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _leaves(node, at=(), field=()):
|
|
722
|
+
if isinstance(node, dict):
|
|
723
|
+
unit = node.get("unit") if "quantity" in node else None
|
|
724
|
+
typed = isinstance(unit, str) and unit.strip() != ""
|
|
725
|
+
for key, value in node.items():
|
|
726
|
+
where = at + (str(key),)
|
|
727
|
+
if typed and key == "unit":
|
|
728
|
+
continue
|
|
729
|
+
if typed and key == "quantity":
|
|
730
|
+
yield where, where, f"{_written(value)} {unit}"
|
|
731
|
+
else:
|
|
732
|
+
yield from _leaves(value, where, where)
|
|
733
|
+
elif isinstance(node, list):
|
|
734
|
+
for index, value in enumerate(node):
|
|
735
|
+
yield from _leaves(value, at + (str(index),), field)
|
|
736
|
+
else:
|
|
737
|
+
yield at, field, _written(node)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _stated_quantities(root, uid, data, units):
|
|
741
|
+
own = own_quantities(root, uid, data)
|
|
742
|
+
kind, name = corpus.kind_of(uid, own), corpus.name_of(uid, own) or uid
|
|
743
|
+
prose = set(prose_paths(root, uid, own))
|
|
744
|
+
written, held, entry_held = {}, {}, {}
|
|
745
|
+
# @req> REQ-15854616@QqNmd3FR7cNC k6ksii
|
|
746
|
+
for at, field, value in _leaves(own):
|
|
747
|
+
entry = at[:2] if kind == "data" and at[:1] == ("entries",) else None
|
|
748
|
+
if any(at[:depth] in prose for depth in range(len(at) + 1)):
|
|
749
|
+
written.setdefault(entry, []).append(value)
|
|
750
|
+
elif entry:
|
|
751
|
+
for quantity in _quantities(value, units):
|
|
752
|
+
held.setdefault(quantity, ".".join((name, *field[1:])))
|
|
753
|
+
entry_held.setdefault(entry, set()).add(quantity)
|
|
754
|
+
if kind == "parameter":
|
|
755
|
+
unit = own.get("unit") if isinstance(own.get("unit"), str) else ""
|
|
756
|
+
for key in corpus.entries(own) or {}:
|
|
757
|
+
for quantity in _quantities(f"{key} {unit}", units):
|
|
758
|
+
held.setdefault(quantity, name)
|
|
759
|
+
# @req+ REQ-15854616@QqNmd3FR7cNC 7jvcuc
|
|
760
|
+
stated = set()
|
|
761
|
+
for entry, values in written.items():
|
|
762
|
+
restated = entry_held.get(entry, set())
|
|
763
|
+
numbers = {number for number, _ in restated}
|
|
764
|
+
restated_units = {unit for _, unit in restated}
|
|
765
|
+
stated |= {(number, unit)
|
|
766
|
+
for number, unit in _quantities("\n".join(values), units)
|
|
767
|
+
if number not in numbers or unit and unit not in restated_units}
|
|
768
|
+
# @req- 7jvcuc
|
|
769
|
+
return stated, held
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _unit(written, units=ISO_UNITS):
|
|
773
|
+
spelling = written.lower()
|
|
774
|
+
return units.get(spelling, spelling)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _units(records):
|
|
778
|
+
held = corpus.reachable(records).get(VALUE_TYPES, VALUE_TYPES)
|
|
779
|
+
# @req> REQ-36436662@AUcYocp2yrFS qrxqss
|
|
780
|
+
if corpus.kind_of(held, records.get(held)) != "data":
|
|
781
|
+
return ISO_UNITS
|
|
782
|
+
# @req> REQ-57721566@kHs1aOC7uW84 udbc2z
|
|
783
|
+
return {spelling.lower(): symbol for _, symbol, spelling in _spellings(records[held])}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _spellings(held):
|
|
787
|
+
return [(str(key), str(symbol), str(spelling))
|
|
788
|
+
for key, stated in (corpus.entries(held) or {}).items()
|
|
789
|
+
if isinstance(stated, dict) and isinstance(stated.get("units"), dict)
|
|
790
|
+
for symbol, unit in stated["units"].items()
|
|
791
|
+
for spelling in [symbol, *(unit.get("spellings")
|
|
792
|
+
if isinstance(unit, dict)
|
|
793
|
+
and isinstance(unit.get("spellings"), list)
|
|
794
|
+
else [])]
|
|
795
|
+
if str(spelling).strip()]
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def shared_quantities(records, root):
|
|
799
|
+
held = corpus.reachable(records).get(VALUE_TYPES, VALUE_TYPES)
|
|
800
|
+
typed = corpus.kind_of(held, records.get(held)) == "data"
|
|
801
|
+
units = _units(records)
|
|
802
|
+
# @req+ REQ-31827606@wZxuCl2JsAvY lfsavh
|
|
803
|
+
where, structured = {}, {}
|
|
804
|
+
for uid, data in records.items():
|
|
805
|
+
# @req> REQ-71775839@o0Rw6sW3xCGB 2afre5
|
|
806
|
+
if typed and uid == held:
|
|
807
|
+
continue
|
|
808
|
+
# @req> REQ-74163593@shkbFgp8gpvT bzkfod
|
|
809
|
+
if data.get("status") in RETIRED:
|
|
810
|
+
continue
|
|
811
|
+
written, held_at = _stated_quantities(root, uid, data, units)
|
|
812
|
+
for quantity in written:
|
|
813
|
+
where.setdefault(quantity, []).append(uid)
|
|
814
|
+
for quantity, address in held_at.items():
|
|
815
|
+
structured.setdefault(quantity, []).append((uid, address))
|
|
816
|
+
problems = []
|
|
817
|
+
for (value, unit), uids in sorted(where.items(), key=lambda pair: str(pair[0])):
|
|
818
|
+
if len(uids) > 1:
|
|
819
|
+
# @req> REQ-30898926@Vu28eWaG_1mE nn2awc
|
|
820
|
+
problems.append(
|
|
821
|
+
f"{', '.join(sorted(uids))} each state {value} {unit}".rstrip()
|
|
822
|
+
+ " -- a quantity more than one statement makes belongs in a "
|
|
823
|
+
f"single parameter, referenced {corpus.NAME_FORM}"
|
|
824
|
+
)
|
|
825
|
+
# @req> REQ-30898926@Vu28eWaG_1mE gja424
|
|
826
|
+
problems += [f"{uid} states {value} {unit}".rstrip()
|
|
827
|
+
+ f", which {address} holds -- reference it as ${{{address}}}"
|
|
828
|
+
for uid in sorted(uids) if unit
|
|
829
|
+
for holder, address in sorted(structured.get((value, unit), []))
|
|
830
|
+
if holder != uid]
|
|
831
|
+
return problems
|
|
832
|
+
# @req- lfsavh
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _assessed(uid, data, records, canonical, known, past):
|
|
836
|
+
def held_data(address):
|
|
837
|
+
return records.get(canonical.get(address, address))
|
|
838
|
+
|
|
839
|
+
# @req+ REQ-50160324@rCxgTJz0CeZX gxi4g4
|
|
840
|
+
unreadable = set()
|
|
841
|
+
for address in corpus.dependencies(data):
|
|
842
|
+
found = corpus.ADDRESS.match(str(address))
|
|
843
|
+
if found and found.group(1) in known and held_data(found.group(1)) is None:
|
|
844
|
+
unreadable.add(found.group(1))
|
|
845
|
+
# @req> REQ-30898926@Vu28eWaG_1mE xe6om6
|
|
846
|
+
problems = [f"{uid}: depends on {target}, which could not be read -- "
|
|
847
|
+
f"fix {target} first" for target in sorted(unreadable)]
|
|
848
|
+
# @req- gxi4g4
|
|
849
|
+
wanted = set(corpus.pin_addresses(held_data, data))
|
|
850
|
+
pins = corpus.mapping(data, "assessed")
|
|
851
|
+
stated = data.get("assessed")
|
|
852
|
+
held = set(stated) if isinstance(stated, dict) else set()
|
|
853
|
+
# @req> REQ-67539399@tuMwHXTE6ACx pcswfe
|
|
854
|
+
for target in sorted(held - wanted, key=str):
|
|
855
|
+
found = corpus.ADDRESS.match(str(target))
|
|
856
|
+
if found and found.group(1) in unreadable:
|
|
857
|
+
continue
|
|
858
|
+
# @req> REQ-30898926@Vu28eWaG_1mE phxt2t
|
|
859
|
+
problems.append(
|
|
860
|
+
f"{uid}: pins {target}, which is neither related nor referenced "
|
|
861
|
+
f"-- relate or reference {target} again, or remove the pin from "
|
|
862
|
+
f"{uid}'s assessed by hand"
|
|
863
|
+
)
|
|
864
|
+
# @req> REQ-81493991@71cbxqAtqZTk w5qzvp
|
|
865
|
+
if data.get("status") in RETIRED:
|
|
866
|
+
return problems
|
|
867
|
+
for target in sorted(wanted):
|
|
868
|
+
item_uid, rest = corpus.split_address(target)
|
|
869
|
+
stamped = canonical.get(item_uid, item_uid)
|
|
870
|
+
if stamped not in records:
|
|
871
|
+
continue
|
|
872
|
+
held = corpus.address_stamp_of(stamped, records[stamped], rest)
|
|
873
|
+
pinned = pins.get(target)
|
|
874
|
+
# @req> REQ-42390302@QOC4GhS7SkFg w3y4p3
|
|
875
|
+
if past and (not isinstance(pinned, str) or pinned != held):
|
|
876
|
+
# @req> REQ-24608716@rrTjNN2Ay0D0 bzyf37
|
|
877
|
+
# @req> REQ-30898926@Vu28eWaG_1mE utkrv6
|
|
878
|
+
problems.append(
|
|
879
|
+
f"{uid}: suspect link: {target} -- reread both, then "
|
|
880
|
+
"`reqctl relate` a requirement or `reqctl revise "
|
|
881
|
+
f"{uid} --ack` the rest to pin it"
|
|
882
|
+
+ history.lines(past.pinned(stamped, records[stamped], rest, pinned)))
|
|
883
|
+
return problems
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _approved(records):
|
|
887
|
+
return {uid: data for uid, data in records.items()
|
|
888
|
+
if data.get("status") == "approved"}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def conflicts(records):
|
|
892
|
+
approved = _approved(records)
|
|
893
|
+
declared = []
|
|
894
|
+
# @req+ REQ-77022154@-t-5C3fYQMS3 loruif
|
|
895
|
+
for uid, data in approved.items():
|
|
896
|
+
for target, relation in sorted(corpus.mapping(data, "relations").items()):
|
|
897
|
+
if relation == "conflicts_with" and target in approved:
|
|
898
|
+
declared.append(tuple(sorted((uid, target))))
|
|
899
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ssnfls
|
|
900
|
+
return [
|
|
901
|
+
f"{left} and {right} are both approved and declare conflicts_with -- "
|
|
902
|
+
"resolve it by deprecating one, superseding both, or rewriting them"
|
|
903
|
+
for left, right in sorted(set(declared))
|
|
904
|
+
]
|
|
905
|
+
# @req- loruif
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def supersedes(records):
|
|
909
|
+
approved = _approved(records)
|
|
910
|
+
# @req> REQ-53480164@5yFhCLJmuTBt qxi4hb
|
|
911
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 2evdh5
|
|
912
|
+
return [
|
|
913
|
+
f"{uid} supersedes {target}, which is still approved -- "
|
|
914
|
+
"revise it to superseded or deprecated in this same change"
|
|
915
|
+
for uid, data in approved.items()
|
|
916
|
+
for target, relation in sorted(corpus.mapping(data, "relations").items())
|
|
917
|
+
if relation == "supersedes" and target in approved
|
|
918
|
+
]
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
LEANS_ON = ("derives_from", "depends_on", "constrains")
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def approved_relations(records):
|
|
925
|
+
approved = _approved(records)
|
|
926
|
+
problems = []
|
|
927
|
+
# @req> REQ-42068162@TlRyGA-UJLlq hbpm4t
|
|
928
|
+
for uid, data in sorted(approved.items()):
|
|
929
|
+
for target, relation in sorted(corpus.mapping(data, "relations").items()):
|
|
930
|
+
if relation not in LEANS_ON:
|
|
931
|
+
continue
|
|
932
|
+
held = records.get(target)
|
|
933
|
+
if held is None or target in approved:
|
|
934
|
+
continue
|
|
935
|
+
# @req> REQ-30898926@Vu28eWaG_1mE bcyrxr
|
|
936
|
+
problems.append(
|
|
937
|
+
f"{uid} is approved but {relation} {target}, which is "
|
|
938
|
+
f"{held.get('status')} -- approve {target}, or revise {uid} "
|
|
939
|
+
"in this same change"
|
|
940
|
+
)
|
|
941
|
+
return problems
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _approved_bindings(records):
|
|
945
|
+
canonical = corpus.reachable(records)
|
|
946
|
+
approved = _approved(records)
|
|
947
|
+
problems = []
|
|
948
|
+
for uid, data in sorted(approved.items()):
|
|
949
|
+
for address in corpus.references(data) + corpus.concept_references(data):
|
|
950
|
+
target, _ = corpus.split_address(address)
|
|
951
|
+
stated = canonical.get(target, target)
|
|
952
|
+
held = records.get(stated)
|
|
953
|
+
if held is None or stated in approved:
|
|
954
|
+
continue
|
|
955
|
+
# @req> REQ-30898926@Vu28eWaG_1mE p5bokx
|
|
956
|
+
problems.append(
|
|
957
|
+
f"{uid} is approved but stated against {target}, which is "
|
|
958
|
+
f"{held.get('status')} -- approve {target}, or revise {uid}"
|
|
959
|
+
)
|
|
960
|
+
return problems
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _shared_words(records):
|
|
964
|
+
claims = {}
|
|
965
|
+
for uid, data in sorted(records.items()):
|
|
966
|
+
if corpus.kind_of(uid, data) == "term":
|
|
967
|
+
for word in _term_words(data):
|
|
968
|
+
claims.setdefault(word.strip().casefold(), ([], word))[0].append(uid)
|
|
969
|
+
problems = []
|
|
970
|
+
for _, (uids, word) in sorted(claims.items()):
|
|
971
|
+
# @req> REQ-18059226@M_Z00FMwux9p 2troxr
|
|
972
|
+
if len(set(uids)) > 1:
|
|
973
|
+
# @req> REQ-30898926@Vu28eWaG_1mE yrx3ss
|
|
974
|
+
problems.append(
|
|
975
|
+
f"{', '.join(sorted(set(uids)))} each claim \"{word}\" -- a "
|
|
976
|
+
"reader cannot tell which definition governs; merge them or "
|
|
977
|
+
"reword one"
|
|
978
|
+
)
|
|
979
|
+
elif len(uids) > 1:
|
|
980
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 3jvhux
|
|
981
|
+
problems.append(f"{uids[0]}: claims \"{word}\" more than once -- "
|
|
982
|
+
"restate its aliases with --alias, leaving out the "
|
|
983
|
+
"repeat, or clear them with --no-alias")
|
|
984
|
+
return problems
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _components(edges):
|
|
988
|
+
order, low, live, stack, found = {}, {}, set(), [], []
|
|
989
|
+
reached = 0
|
|
990
|
+
for root in sorted(edges):
|
|
991
|
+
if root in order:
|
|
992
|
+
continue
|
|
993
|
+
work = [(root, iter(edges.get(root, ())))]
|
|
994
|
+
order[root] = low[root] = reached
|
|
995
|
+
reached += 1
|
|
996
|
+
stack.append(root)
|
|
997
|
+
live.add(root)
|
|
998
|
+
while work:
|
|
999
|
+
uid, targets = work[-1]
|
|
1000
|
+
for target in targets:
|
|
1001
|
+
if target not in order:
|
|
1002
|
+
order[target] = low[target] = reached
|
|
1003
|
+
reached += 1
|
|
1004
|
+
stack.append(target)
|
|
1005
|
+
live.add(target)
|
|
1006
|
+
work.append((target, iter(edges.get(target, ()))))
|
|
1007
|
+
break
|
|
1008
|
+
if target in live:
|
|
1009
|
+
low[uid] = min(low[uid], order[target])
|
|
1010
|
+
else:
|
|
1011
|
+
work.pop()
|
|
1012
|
+
if work:
|
|
1013
|
+
low[work[-1][0]] = min(low[work[-1][0]], low[uid])
|
|
1014
|
+
if low[uid] == order[uid]:
|
|
1015
|
+
group = []
|
|
1016
|
+
while True:
|
|
1017
|
+
held = stack.pop()
|
|
1018
|
+
live.discard(held)
|
|
1019
|
+
group.append(held)
|
|
1020
|
+
if held == uid:
|
|
1021
|
+
break
|
|
1022
|
+
found.append(tuple(sorted(group)))
|
|
1023
|
+
return sorted(found)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _round_trip(edges, group):
|
|
1027
|
+
held, trail = set(group), []
|
|
1028
|
+
uid = group[0]
|
|
1029
|
+
while uid not in trail:
|
|
1030
|
+
trail.append(uid)
|
|
1031
|
+
uid = min(target for target in edges[uid] if target in held)
|
|
1032
|
+
return trail[trail.index(uid):] + [uid]
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _caught(edges, group):
|
|
1036
|
+
round_trip = _round_trip(edges, group)
|
|
1037
|
+
others = sorted(set(group) - set(round_trip))
|
|
1038
|
+
return " -> ".join(round_trip) + (f", and {', '.join(others)}" if others else "")
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _cycle_problems(edges, noun, tail, step):
|
|
1042
|
+
# @req> REQ-30898926@Vu28eWaG_1mE vd7u6s
|
|
1043
|
+
return [
|
|
1044
|
+
f"{_caught(edges, group)}: circular {noun}, so {tail} -- {step}"
|
|
1045
|
+
for group in _components(edges)
|
|
1046
|
+
if len(group) > 1 or group[0] in edges.get(group[0], ())
|
|
1047
|
+
]
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _circular_definitions(records):
|
|
1051
|
+
# @req+ REQ-49321694@sgj-Dt7t_niN ijccb5
|
|
1052
|
+
canonical = corpus.reachable(records)
|
|
1053
|
+
edges = {uid: sorted({canonical.get(found.partition(".")[0], found)
|
|
1054
|
+
for _, found in corpus.CONCEPT_LINK.findall(
|
|
1055
|
+
str(corpus.term_fields(data).get("definition")))})
|
|
1056
|
+
for uid, data in records.items()
|
|
1057
|
+
if corpus.kind_of(uid, data) == "term"}
|
|
1058
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 46diyb
|
|
1059
|
+
return _cycle_problems(edges, "definition",
|
|
1060
|
+
"none of these means anything before the others",
|
|
1061
|
+
"reword one definition with --definition so it no "
|
|
1062
|
+
"longer links the next")
|
|
1063
|
+
# @req- ijccb5
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _entry_selection(records, root):
|
|
1067
|
+
canonical = corpus.reachable(records)
|
|
1068
|
+
problems = []
|
|
1069
|
+
for uid, data in sorted(records.items()):
|
|
1070
|
+
links = corpus.concept_references(data)
|
|
1071
|
+
for address in corpus.references(data) + links:
|
|
1072
|
+
target, rest = corpus.split_address(address)
|
|
1073
|
+
if rest is None:
|
|
1074
|
+
continue
|
|
1075
|
+
held = records.get(canonical.get(target, target))
|
|
1076
|
+
if held is None:
|
|
1077
|
+
continue
|
|
1078
|
+
existing = corpus.entries(held)
|
|
1079
|
+
key, *steps = rest.split(".")
|
|
1080
|
+
# @req+ REQ-15211173@2tindbOlAUEJ sggzwh
|
|
1081
|
+
# @req+ REQ-11930677@dH7Pu7k4K4YI xxvuas
|
|
1082
|
+
block = corpus.held_blocks(root, canonical.get(target, target), held).get(key)
|
|
1083
|
+
if block is not None:
|
|
1084
|
+
if not steps:
|
|
1085
|
+
# @req> REQ-30898926@Vu28eWaG_1mE k6ab75
|
|
1086
|
+
problems.append(f"{uid}: selects {address}, which names {key} of "
|
|
1087
|
+
f"{target} and none of its block members -- "
|
|
1088
|
+
f"reword {uid} to select one of its members")
|
|
1089
|
+
elif not isinstance(block.get(steps[0], {}), dict):
|
|
1090
|
+
# @req> REQ-30898926@Vu28eWaG_1mE kssexg
|
|
1091
|
+
problems.append(f"{uid}: selects {address} but {steps[0]} of {key} of "
|
|
1092
|
+
f"{target} holds no fields of its own -- "
|
|
1093
|
+
f"reword {uid} to select a member of {key} "
|
|
1094
|
+
f"that holds fields, or state {steps[0]} of "
|
|
1095
|
+
f"{target} as a record of fields")
|
|
1096
|
+
continue
|
|
1097
|
+
fields = block
|
|
1098
|
+
for step in steps:
|
|
1099
|
+
if not isinstance(fields, dict) or step not in fields:
|
|
1100
|
+
# @req> REQ-30898926@Vu28eWaG_1mE kijt3f
|
|
1101
|
+
problems.append(f"{uid}: selects {address} but {key} of "
|
|
1102
|
+
f"{target} holds no {step!r} -- reword "
|
|
1103
|
+
f"{uid} to select what {key} holds, or "
|
|
1104
|
+
f"add {step!r} to it")
|
|
1105
|
+
break
|
|
1106
|
+
fields = fields[step]
|
|
1107
|
+
continue
|
|
1108
|
+
# @req- xxvuas
|
|
1109
|
+
if address in links and "." in rest:
|
|
1110
|
+
# @req> REQ-30898926@Vu28eWaG_1mE xeqgyv
|
|
1111
|
+
problems.append(f"{uid}: links {address}, which names neither an "
|
|
1112
|
+
f"entry nor a block member of {target} -- reword "
|
|
1113
|
+
f"{uid} to link {target} or one of its entries")
|
|
1114
|
+
continue
|
|
1115
|
+
# @req- sggzwh
|
|
1116
|
+
# @req+ REQ-62329029@wjRs5FRIhJ3C rc5mmy
|
|
1117
|
+
if existing is None:
|
|
1118
|
+
# @req> REQ-30898926@Vu28eWaG_1mE hagaom
|
|
1119
|
+
problems.append(
|
|
1120
|
+
f"{uid}: selects {address} but {target} has no entries -- "
|
|
1121
|
+
f"reword {uid} to reference {target} whole, or give "
|
|
1122
|
+
f"{target} entries"
|
|
1123
|
+
)
|
|
1124
|
+
elif rest == "default":
|
|
1125
|
+
if held.get("default") is None:
|
|
1126
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 6lxfme
|
|
1127
|
+
problems.append(
|
|
1128
|
+
f"{uid}: selects ${{{target}.default}} but {target} "
|
|
1129
|
+
f"names no default -- name one with `reqctl revise "
|
|
1130
|
+
f"{target} --default`, or reword {uid} to select an "
|
|
1131
|
+
"entry"
|
|
1132
|
+
)
|
|
1133
|
+
# @req> REQ-93367131@liYuAavoXSA4 vye4xb
|
|
1134
|
+
elif rest == key and key in existing and any(
|
|
1135
|
+
isinstance(fields, dict) and rest in fields
|
|
1136
|
+
for fields in existing.values()):
|
|
1137
|
+
# @req> REQ-30898926@Vu28eWaG_1mE bcq3ce
|
|
1138
|
+
problems.append(
|
|
1139
|
+
f"{uid}: selects {address}, which names both an entry of "
|
|
1140
|
+
f"{target} and a field its entries carry -- rename one"
|
|
1141
|
+
)
|
|
1142
|
+
elif corpus.flagged(held, rest) is not None:
|
|
1143
|
+
stated = [fields[rest] for fields in existing.values()
|
|
1144
|
+
if isinstance(fields, dict) and rest in fields
|
|
1145
|
+
and not isinstance(fields[rest], bool)]
|
|
1146
|
+
# @req> REQ-28975717@sXPovM6T9HGc p5hfxl
|
|
1147
|
+
if stated:
|
|
1148
|
+
# @req> REQ-30898926@Vu28eWaG_1mE burvbt
|
|
1149
|
+
problems.append(
|
|
1150
|
+
f"{uid}: selects {address} but {target} states {rest} "
|
|
1151
|
+
f"{stated[0]!r} -- set {rest} to true or false on each "
|
|
1152
|
+
f"entry of {target} with --set"
|
|
1153
|
+
)
|
|
1154
|
+
# @req> REQ-10000795@VNUKO0iJNjTj bg3tyu
|
|
1155
|
+
elif not corpus.flagged(held, rest):
|
|
1156
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 5t4oer
|
|
1157
|
+
problems.append(
|
|
1158
|
+
f"{uid}: selects {address} but no entry of {target} "
|
|
1159
|
+
f"is {rest} -- set {rest}=true on an entry of "
|
|
1160
|
+
f"{target} with --set, or reword {uid}"
|
|
1161
|
+
)
|
|
1162
|
+
elif key not in existing:
|
|
1163
|
+
# @req> REQ-30898926@Vu28eWaG_1mE i3ezb4
|
|
1164
|
+
problems.append(
|
|
1165
|
+
f"{uid}: selects {address} but {target} has no entry "
|
|
1166
|
+
f"{key!r} -- reword {uid} to select an entry {target} "
|
|
1167
|
+
f"holds, or add {key!r} to {target}"
|
|
1168
|
+
)
|
|
1169
|
+
elif "." in rest:
|
|
1170
|
+
fields = existing[key]
|
|
1171
|
+
for field in rest.split(".")[1:]:
|
|
1172
|
+
if not isinstance(fields, dict) or field not in fields:
|
|
1173
|
+
# @req> REQ-30898926@Vu28eWaG_1mE jo2xia
|
|
1174
|
+
problems.append(
|
|
1175
|
+
f"{uid}: selects {address} but entry {key!r} of "
|
|
1176
|
+
f"{target} has no field {field!r} -- reword {uid} "
|
|
1177
|
+
f"to select a field it holds, or set {field!r} on "
|
|
1178
|
+
"it with --set"
|
|
1179
|
+
)
|
|
1180
|
+
break
|
|
1181
|
+
fields = fields[field]
|
|
1182
|
+
# @req- rc5mmy
|
|
1183
|
+
return problems
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _addresses(records, uid):
|
|
1187
|
+
return {found for found in (uid, corpus.name_of(uid, records.get(uid) or {}))
|
|
1188
|
+
if found}
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _fault_paths(validator, item):
|
|
1192
|
+
return (tuple(str(step) for step in fault.absolute_path)
|
|
1193
|
+
for fault in validator.iter_errors(item))
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _refused_at(paths, at):
|
|
1197
|
+
return any(path[:len(at)] == at for path in paths)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _judgement(root, uid, data, judged):
|
|
1201
|
+
if uid not in judged:
|
|
1202
|
+
schema = corpus.schema_for(root, corpus.kind_of(uid, data),
|
|
1203
|
+
corpus.name_of(uid, data))
|
|
1204
|
+
validator = corpus.validator(schema)
|
|
1205
|
+
judged[uid] = (validator, tuple(_fault_paths(validator, data)),
|
|
1206
|
+
(str(root), uid, corpus.digest(repr(schema)),
|
|
1207
|
+
corpus.digest(repr(data))))
|
|
1208
|
+
return judged[uid]
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
_ADMITTED: dict[tuple, bool] = {}
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _admits_link(root, uid, data, where, field, link, judged):
|
|
1215
|
+
validator, faults, identity = _judgement(root, uid, data, judged)
|
|
1216
|
+
at = ("entries", *where, str(field))
|
|
1217
|
+
if _refused_at(faults, at):
|
|
1218
|
+
return False
|
|
1219
|
+
key = (*identity, where, str(field), link)
|
|
1220
|
+
if key not in _ADMITTED:
|
|
1221
|
+
candidate = copy.deepcopy(data)
|
|
1222
|
+
held = corpus.entries(candidate)
|
|
1223
|
+
for step in where:
|
|
1224
|
+
held = held[step]
|
|
1225
|
+
held[field] = [link] if isinstance(held.get(field), list) else link
|
|
1226
|
+
_ADMITTED[key] = not _refused_at(_fault_paths(validator, candidate), at)
|
|
1227
|
+
return _ADMITTED[key]
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
PROSE = "x-prose"
|
|
1231
|
+
OBSERVED = "x-observed"
|
|
1232
|
+
VERBATIM = "x-verbatim"
|
|
1233
|
+
DATE = "date"
|
|
1234
|
+
# @req+ REQ-62072712@m81ZZqYdquaS 5ii662
|
|
1235
|
+
DATED = FormatChecker(formats=())
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
@DATED.checks(DATE, raises=ValueError)
|
|
1239
|
+
def _dated(value):
|
|
1240
|
+
return isinstance(value, datetime.date) or (
|
|
1241
|
+
isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value)
|
|
1242
|
+
and datetime.date.fromisoformat(value))
|
|
1243
|
+
# @req- 5ii662
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
UNMARKED = "\0not marked\0"
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _unmarked(node, marks):
|
|
1250
|
+
if isinstance(node, dict):
|
|
1251
|
+
if marks(node):
|
|
1252
|
+
return {"const": UNMARKED}
|
|
1253
|
+
return {key: _unmarked(value, marks) for key, value in node.items()}
|
|
1254
|
+
if isinstance(node, list):
|
|
1255
|
+
return [_unmarked(one, marks) for one in node]
|
|
1256
|
+
return node
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
_DERIVED: dict[tuple[int, object], tuple[object, object]] = {}
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _marked_paths(root, uid, data, marks):
|
|
1263
|
+
schema = corpus.schema_for(root, corpus.kind_of(uid, data),
|
|
1264
|
+
corpus.name_of(uid, data))
|
|
1265
|
+
held = _DERIVED.get((id(schema), marks))
|
|
1266
|
+
if held is None or held[0] is not schema:
|
|
1267
|
+
held = _DERIVED[id(schema), marks] = (schema, _unmarked(schema, marks))
|
|
1268
|
+
return [tuple(str(step) for step in fault.absolute_path)
|
|
1269
|
+
for fault in corpus.validator(held[1]).iter_errors(data)
|
|
1270
|
+
if fault.validator == "const" and fault.validator_value == UNMARKED]
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _prose(node):
|
|
1274
|
+
return node.get(PROSE) is True
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _observed(node):
|
|
1278
|
+
return node.get(OBSERVED) is True
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _verbatim(node):
|
|
1282
|
+
return node.get(VERBATIM) is True
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _date(node):
|
|
1286
|
+
return node.get("format") == DATE
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
@functools.cache
|
|
1290
|
+
def _entry_of(target):
|
|
1291
|
+
return lambda node: node.get(corpus.ENTRY_OF) == target
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def prose_paths(root, uid, data):
|
|
1295
|
+
return _marked_paths(root, uid, data, _prose)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def observed_paths(root, uid, data):
|
|
1299
|
+
# @req> REQ-48500355@rnstkLKM48vN t3fuqq
|
|
1300
|
+
return _marked_paths(root, uid, data, _observed)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
# @req> REQ-20917759@zhgr-pfTbobT fi5bwx
|
|
1304
|
+
def verbatim_paths(root, uid, data):
|
|
1305
|
+
return _marked_paths(root, uid, data, _verbatim) if corpus.kind_of(uid, data) == "data" else []
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def dated_paths(root, uid, data):
|
|
1309
|
+
# @req> REQ-62072712@m81ZZqYdquaS npwa4k
|
|
1310
|
+
return _marked_paths(root, uid, data, _date)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _dropped(node, paths, at=()):
|
|
1314
|
+
if isinstance(node, dict):
|
|
1315
|
+
return {key: _dropped(value, paths, at + (str(key),))
|
|
1316
|
+
for key, value in node.items()
|
|
1317
|
+
if at + (str(key),) not in paths}
|
|
1318
|
+
if isinstance(node, list):
|
|
1319
|
+
return [_dropped(one, paths, at + (str(index),))
|
|
1320
|
+
for index, one in enumerate(node)
|
|
1321
|
+
if at + (str(index),) not in paths]
|
|
1322
|
+
return node
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _without(root, uid, data, *finders):
|
|
1326
|
+
try:
|
|
1327
|
+
paths = frozenset(one for find in finders for one in find(root, uid, data))
|
|
1328
|
+
# @req> REQ-14895892@JjrTwHoJqTRe in6ljr
|
|
1329
|
+
except corpus.RefusedCopy:
|
|
1330
|
+
raise
|
|
1331
|
+
except corpus.ReqctlError:
|
|
1332
|
+
return data
|
|
1333
|
+
return _dropped(data, paths) if paths else data
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
_OWN: dict[tuple, object] = {}
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _own(root, uid, data, *finders):
|
|
1340
|
+
try:
|
|
1341
|
+
schema = corpus.schema_for(root, corpus.kind_of(uid, data),
|
|
1342
|
+
corpus.name_of(uid, data))
|
|
1343
|
+
except corpus.ReqctlError:
|
|
1344
|
+
return _without(root, uid, data, *finders)
|
|
1345
|
+
key = (finders, str(root), uid, id(schema), repr(data))
|
|
1346
|
+
if key not in _OWN:
|
|
1347
|
+
held = _without(root, uid, data, *finders)
|
|
1348
|
+
_OWN[key] = None if held is data else held
|
|
1349
|
+
return data if _OWN[key] is None else _OWN[key]
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def own_words(root, uid, data):
|
|
1353
|
+
# @req> REQ-48500355@rnstkLKM48vN rwffnk
|
|
1354
|
+
# @req> REQ-20917759@zhgr-pfTbobT rgfts7
|
|
1355
|
+
return _own(root, uid, data, observed_paths, verbatim_paths)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def own_quantities(root, uid, data):
|
|
1359
|
+
# @req> REQ-62072712@m81ZZqYdquaS hlxm3m
|
|
1360
|
+
# @req> REQ-20917759@zhgr-pfTbobT qzvrsz
|
|
1361
|
+
return _own(root, uid, data, observed_paths, dated_paths, verbatim_paths)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _declares_prose(root, uid, data, where, field, refused):
|
|
1365
|
+
if uid not in refused:
|
|
1366
|
+
refused[uid] = prose_paths(root, uid, data)
|
|
1367
|
+
return _refused_at(refused[uid], ("entries", *where, str(field)))
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _entry_term_fields(records, root):
|
|
1371
|
+
refused, judged = {}, {}
|
|
1372
|
+
worded = {found for held_uid, held in records.items()
|
|
1373
|
+
if corpus.kind_of(held_uid, held) == "term"
|
|
1374
|
+
for found in (held_uid, corpus.name_of(held_uid, held)) if found}
|
|
1375
|
+
claimed, words_of = {}, {}
|
|
1376
|
+
for uid, data in sorted(records.items()):
|
|
1377
|
+
if corpus.kind_of(uid, data) == "term":
|
|
1378
|
+
for word in _term_words(data):
|
|
1379
|
+
spelled = word.replace("_", " ").casefold()
|
|
1380
|
+
for address in _addresses(records, uid):
|
|
1381
|
+
words_of.setdefault(address, set()).add(spelled)
|
|
1382
|
+
if data.get("status") == "approved":
|
|
1383
|
+
claimed.setdefault(spelled, (uid, word))
|
|
1384
|
+
|
|
1385
|
+
def walk(uid, data, where, fields, seen):
|
|
1386
|
+
if len(seen) >= NESTING:
|
|
1387
|
+
return []
|
|
1388
|
+
problems = []
|
|
1389
|
+
seen = seen + (id(fields),)
|
|
1390
|
+
for field, value in sorted(fields.items(),
|
|
1391
|
+
key=lambda item: str(item[0])):
|
|
1392
|
+
pieces = value if isinstance(value, list) else [value]
|
|
1393
|
+
linked = {found for piece in pieces if isinstance(piece, str)
|
|
1394
|
+
for _, found in corpus.CONCEPT_LINK.findall(piece)
|
|
1395
|
+
if found in worded}
|
|
1396
|
+
spelled = str(field).replace("_", " ").casefold()
|
|
1397
|
+
named = claimed.get(spelled)
|
|
1398
|
+
# @req> REQ-31592505@2C7Za06pRzA2 vtxlpz
|
|
1399
|
+
if named and not linked & _addresses(records, named[0]):
|
|
1400
|
+
term_uid, word = named
|
|
1401
|
+
held_by = corpus.name_of(term_uid, records[term_uid]) or term_uid
|
|
1402
|
+
link = f"[{word}]({held_by})"
|
|
1403
|
+
if _admits_link(root, uid, data, where, field, link, judged):
|
|
1404
|
+
# @req> REQ-30898926@Vu28eWaG_1mE vl4u63
|
|
1405
|
+
problems.append(
|
|
1406
|
+
f"{uid}: entry {'.'.join(where)} field {field} is "
|
|
1407
|
+
f'named for "{word}" but its value does not link '
|
|
1408
|
+
f"{held_by} -- write the value with {link}, or "
|
|
1409
|
+
"rename the field"
|
|
1410
|
+
)
|
|
1411
|
+
# @req> REQ-51326810@tZsTSo6pwmwE xx2sp7
|
|
1412
|
+
if not _declares_prose(root, uid, data, where, field,
|
|
1413
|
+
refused):
|
|
1414
|
+
for target in sorted(linked):
|
|
1415
|
+
if spelled not in words_of.get(target, ()):
|
|
1416
|
+
# @req> REQ-30898926@Vu28eWaG_1mE dzkfxs
|
|
1417
|
+
problems.append(
|
|
1418
|
+
f"{uid}: entry {'.'.join(where)} field "
|
|
1419
|
+
f"{field} links {target} but is not named "
|
|
1420
|
+
"for it -- rename the field to the term's "
|
|
1421
|
+
"words, or unlink the term"
|
|
1422
|
+
)
|
|
1423
|
+
if isinstance(value, dict) and id(value) not in seen:
|
|
1424
|
+
problems += walk(uid, data, where + (str(field),), value, seen)
|
|
1425
|
+
return problems
|
|
1426
|
+
|
|
1427
|
+
problems = []
|
|
1428
|
+
for uid, data in sorted(records.items()):
|
|
1429
|
+
for entry_key, fields in sorted((corpus.entries(data) or {}).items(),
|
|
1430
|
+
key=lambda item: str(item[0])):
|
|
1431
|
+
if isinstance(fields, dict):
|
|
1432
|
+
problems += walk(uid, data, (str(entry_key),), fields, ())
|
|
1433
|
+
return problems
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def coherence(records, root, run=lambda check, *args: check(*args)):
|
|
1437
|
+
return (run(_approved_bindings, records) + run(_shared_words, records)
|
|
1438
|
+
+ run(_circular_definitions, records) + run(_entry_selection, records, root)
|
|
1439
|
+
+ run(_duplicate_names, records) + run(_entry_term_fields, records, root)
|
|
1440
|
+
+ run(_typed_entries, records) + run(_entry_of_values, records, root)
|
|
1441
|
+
+ run(_value_types, records, root) + run(_unit_quantities, records, root)
|
|
1442
|
+
+ run(_listed_units, records) + run(_bounds, records)
|
|
1443
|
+
+ run(_shadowed_blocks, records, root))
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
# @req> REQ-41697188@IRR8A-_NbYw4 pkvemf
|
|
1447
|
+
# @req> REQ-57061306@grhOhf7rpAJV 23uhdj
|
|
1448
|
+
# @req> REQ-77903352@LFt7c94PeLFp leh3qs
|
|
1449
|
+
def _typed_entries(records):
|
|
1450
|
+
held = corpus.reachable(records).get(VALUE_TYPES, VALUE_TYPES)
|
|
1451
|
+
if corpus.kind_of(held, records.get(held)) != "data":
|
|
1452
|
+
return []
|
|
1453
|
+
return [problem
|
|
1454
|
+
for uid, data in sorted(records.items())
|
|
1455
|
+
if corpus.kind_of(uid, data) == "data"
|
|
1456
|
+
for where, fields in _entries_within(corpus.entries(data))
|
|
1457
|
+
if fields is not None
|
|
1458
|
+
and str(fields.get("value_type")) in KEY_SHAPES
|
|
1459
|
+
for problem in _typed(
|
|
1460
|
+
f"{uid}: entry {'.'.join(where)}", fields["value_type"],
|
|
1461
|
+
[(f"quantity {fields['quantity']!r}",
|
|
1462
|
+
_written(fields["quantity"]))] if "quantity" in fields else [],
|
|
1463
|
+
fields.get("unit") or None)]
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _marker_targets(node):
|
|
1467
|
+
if isinstance(node, dict):
|
|
1468
|
+
return ({node[corpus.ENTRY_OF]} if isinstance(node.get(corpus.ENTRY_OF), str)
|
|
1469
|
+
else set()).union(
|
|
1470
|
+
*map(_marker_targets, node.values()))
|
|
1471
|
+
if isinstance(node, list):
|
|
1472
|
+
return set().union(*map(_marker_targets, node))
|
|
1473
|
+
return set()
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _at(node, path):
|
|
1477
|
+
for step in path:
|
|
1478
|
+
node = (node[int(step)] if isinstance(node, list)
|
|
1479
|
+
else next(value for key, value in node.items() if str(key) == step))
|
|
1480
|
+
return node
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
# @req> REQ-93210750@EGkfkA3YBorC zrp6x7
|
|
1484
|
+
def _entry_of_values(records, root):
|
|
1485
|
+
reachable = corpus.reachable(records)
|
|
1486
|
+
held = {address: {str(key) for key in corpus.entries(records[uid]) or {}}
|
|
1487
|
+
for address, uid in reachable.items()
|
|
1488
|
+
if corpus.kind_of(uid, records[uid]) == "data"}
|
|
1489
|
+
problems = []
|
|
1490
|
+
for uid, data in sorted(records.items()):
|
|
1491
|
+
schema = corpus.schema_for(root, corpus.kind_of(uid, data),
|
|
1492
|
+
corpus.name_of(uid, data))
|
|
1493
|
+
for target in sorted(_marker_targets(schema) & held.keys()):
|
|
1494
|
+
for path in sorted(_marked_paths(root, uid, data, _entry_of(target))):
|
|
1495
|
+
value = _at(data, path)
|
|
1496
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ip7mux
|
|
1497
|
+
problems += [f"{uid}: {'.'.join(path)} {member!r} names no entry "
|
|
1498
|
+
f"of {target} -- name one of its entries, or add "
|
|
1499
|
+
f"{member!r} to {target}"
|
|
1500
|
+
for member in (value if isinstance(value, list) else [value])
|
|
1501
|
+
if str(member) not in held[target]]
|
|
1502
|
+
return problems
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
# @req> REQ-28551871@5A5ZA4_Y3ePJ jnnngp
|
|
1506
|
+
def _listed_units(records):
|
|
1507
|
+
held = corpus.reachable(records).get(VALUE_TYPES, VALUE_TYPES)
|
|
1508
|
+
if corpus.kind_of(held, records.get(held)) != "data":
|
|
1509
|
+
return []
|
|
1510
|
+
listed: dict[str, set[str]] = {
|
|
1511
|
+
str(key): set() for key in corpus.entries(records[held]) or {}
|
|
1512
|
+
if str(key) not in UNITLESS}
|
|
1513
|
+
for key, _, spelling in _spellings(records[held]):
|
|
1514
|
+
listed.setdefault(key, set()).add(spelling)
|
|
1515
|
+
stating = [(uid, "", data) for uid, data in sorted(records.items())
|
|
1516
|
+
if corpus.kind_of(uid, data) == "parameter"]
|
|
1517
|
+
stating += [(uid, f" entry {'.'.join(where)}", fields)
|
|
1518
|
+
for uid, data in sorted(records.items())
|
|
1519
|
+
if corpus.kind_of(uid, data) == "data"
|
|
1520
|
+
for where, fields in _entries_within(corpus.entries(data))
|
|
1521
|
+
if fields is not None]
|
|
1522
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ncsger
|
|
1523
|
+
return [f"{uid}:{where} unit {stated['unit']!r} is no unit of {stated['value_type']} "
|
|
1524
|
+
f"-- state a unit {held} lists for it, or add the unit to that entry "
|
|
1525
|
+
f"of {held}"
|
|
1526
|
+
for uid, where, stated in stating
|
|
1527
|
+
if str(stated.get("value_type")) in listed and stated.get("unit") is not None
|
|
1528
|
+
and str(stated["unit"]) not in listed[str(stated["value_type"])]]
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
# @req+ REQ-20469423@IrHZJUp5R992 bplqvo
|
|
1532
|
+
def _number(value):
|
|
1533
|
+
value = value if isinstance(value, (int, float)) else _quantity(_written(value))
|
|
1534
|
+
return (None if isinstance(value, bool) or not isinstance(value, (int, float))
|
|
1535
|
+
or isinstance(value, float) and not math.isfinite(value) else value)
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def _span(value_types, spellings, fields):
|
|
1539
|
+
quantity = _number(fields.get("quantity"))
|
|
1540
|
+
if quantity is None:
|
|
1541
|
+
return None
|
|
1542
|
+
stated = value_types.get(str(fields.get("value_type")))
|
|
1543
|
+
units = stated.get("units") if isinstance(stated, dict) else None
|
|
1544
|
+
units = {str(symbol): unit for symbol, unit in units.items()} if isinstance(units, dict) else {}
|
|
1545
|
+
if fields.get("unit") is None:
|
|
1546
|
+
return None if units else ("", quantity, quantity, quantity)
|
|
1547
|
+
for key, symbol, spelling in spellings:
|
|
1548
|
+
if (key, spelling) == (str(fields.get("value_type")), str(fields["unit"])):
|
|
1549
|
+
unit = units[symbol] if isinstance(units[symbol], dict) else {}
|
|
1550
|
+
low, high = map(_number, (unit.get("factor"), unit.get("factor"))
|
|
1551
|
+
if unit.get("factor") is not None
|
|
1552
|
+
else (unit.get("least"), unit.get("most")))
|
|
1553
|
+
if low is None or high is None:
|
|
1554
|
+
return None
|
|
1555
|
+
try:
|
|
1556
|
+
sizes = sorted((quantity * low, quantity * high))
|
|
1557
|
+
except OverflowError:
|
|
1558
|
+
return None
|
|
1559
|
+
if all(not isinstance(one, float) or math.isfinite(one) for one in sizes):
|
|
1560
|
+
return (symbol, quantity, *sizes)
|
|
1561
|
+
return None
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _less(one, other):
|
|
1565
|
+
return one[1] < other[1] if one[0] == other[0] else one[2] < other[3]
|
|
1566
|
+
# @req- bplqvo
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _bounds(records):
|
|
1570
|
+
held = corpus.reachable(records).get(VALUE_TYPES, VALUE_TYPES)
|
|
1571
|
+
if corpus.kind_of(held, records.get(held)) != "data":
|
|
1572
|
+
return []
|
|
1573
|
+
value_types = {str(key): stated
|
|
1574
|
+
for key, stated in (corpus.entries(records[held]) or {}).items()}
|
|
1575
|
+
spellings = _spellings(records[held])
|
|
1576
|
+
problems = []
|
|
1577
|
+
for uid, data in sorted(records.items()):
|
|
1578
|
+
if corpus.kind_of(uid, data) != "data":
|
|
1579
|
+
continue
|
|
1580
|
+
held_entries = corpus.entries(data) or {}
|
|
1581
|
+
for where, fields in _entries_within(held_entries):
|
|
1582
|
+
if fields is None:
|
|
1583
|
+
continue
|
|
1584
|
+
siblings = _at(held_entries, where[:-1])
|
|
1585
|
+
for bound, words in (("at_least", "less"), ("at_most", "more")):
|
|
1586
|
+
if bound not in fields:
|
|
1587
|
+
continue
|
|
1588
|
+
at = f"{uid}: entry {'.'.join(where)} {bound} {fields[bound]!r}"
|
|
1589
|
+
target = next((value for key, value in siblings.items()
|
|
1590
|
+
if str(key) == str(fields[bound])), None)
|
|
1591
|
+
# @req+ REQ-75293842@Une663jQ8uUZ l7qntp
|
|
1592
|
+
if not isinstance(target, dict) or "quantity" not in target:
|
|
1593
|
+
# @req> REQ-30898926@Vu28eWaG_1mE hvhx2p
|
|
1594
|
+
problems.append(f"{at} names no entry of {uid} stating a quantity "
|
|
1595
|
+
f"-- name a sibling entry that states one, or "
|
|
1596
|
+
f"give {fields[bound]!r} a quantity")
|
|
1597
|
+
continue
|
|
1598
|
+
mine, theirs = fields.get("value_type"), target.get("value_type")
|
|
1599
|
+
if mine is None or str(theirs) != str(mine):
|
|
1600
|
+
# @req> REQ-30898926@Vu28eWaG_1mE be5qdp
|
|
1601
|
+
problems.append(f"{at} states {theirs or 'no value type'}, entry "
|
|
1602
|
+
f"{'.'.join(where)} states {mine or 'no value type'} "
|
|
1603
|
+
f"and the two cannot be compared -- give both "
|
|
1604
|
+
"the same value type")
|
|
1605
|
+
continue
|
|
1606
|
+
# @req- l7qntp
|
|
1607
|
+
# @req+ REQ-58100850@NgW3efYeVw1k p3q7no
|
|
1608
|
+
spans = _span(value_types, spellings, fields), _span(value_types, spellings, target)
|
|
1609
|
+
if None not in spans and _less(*(spans if bound == "at_least" else spans[::-1])):
|
|
1610
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ovhbsq
|
|
1611
|
+
problems.append(f"{at}: quantity {fields.get('quantity')} "
|
|
1612
|
+
f"{fields.get('unit') or ''}".rstrip()
|
|
1613
|
+
+ f" is {words} than {target['quantity']} "
|
|
1614
|
+
f"{target.get('unit') or ''}".rstrip()
|
|
1615
|
+
+ " -- correct this quantity, or the one "
|
|
1616
|
+
f"{bound} names")
|
|
1617
|
+
# @req- p3q7no
|
|
1618
|
+
if "ceiling" not in data:
|
|
1619
|
+
continue
|
|
1620
|
+
ceiling = next((value for key, value in held_entries.items()
|
|
1621
|
+
if str(key) == str(data["ceiling"])), None)
|
|
1622
|
+
# @req> REQ-36099384@oqIHWa_e7CDN uhu3bz
|
|
1623
|
+
if not isinstance(ceiling, dict) or "quantity" not in ceiling:
|
|
1624
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 6vqmrg
|
|
1625
|
+
problems.append(f"{uid}: ceiling {data['ceiling']!r} names no entry of "
|
|
1626
|
+
f"{uid} stating a quantity -- name one with "
|
|
1627
|
+
"--ceiling, or clear it with --no-ceiling")
|
|
1628
|
+
continue
|
|
1629
|
+
# @req+ REQ-27382610@9np_bpPH3xzx 4unc6n
|
|
1630
|
+
top = _span(value_types, spellings, ceiling)
|
|
1631
|
+
# @req> REQ-30898926@Vu28eWaG_1mE hsw6rh
|
|
1632
|
+
problems += [f"{uid}: ceiling {data['ceiling']!r} states "
|
|
1633
|
+
+ f"{ceiling['quantity']} {ceiling.get('unit') or ''}".rstrip()
|
|
1634
|
+
+ f", less than entry {'.'.join(where)} at "
|
|
1635
|
+
+ f"{fields['quantity']} {fields.get('unit') or ''}".rstrip()
|
|
1636
|
+
+ " -- raise the ceiling's quantity, name a higher entry "
|
|
1637
|
+
"with --ceiling, or lower that entry's quantity"
|
|
1638
|
+
for where, fields in _entries_within(held_entries)
|
|
1639
|
+
if fields is not None and ceiling.get("value_type") is not None
|
|
1640
|
+
and str(fields.get("value_type")) == str(ceiling.get("value_type"))
|
|
1641
|
+
and top is not None
|
|
1642
|
+
and (other := _span(value_types, spellings, fields)) is not None
|
|
1643
|
+
and _less(top, other)]
|
|
1644
|
+
# @req- 4unc6n
|
|
1645
|
+
return problems
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
NUMBER_WORDS = ("zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|"
|
|
1649
|
+
"thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|"
|
|
1650
|
+
"twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|"
|
|
1651
|
+
"hundred|thousand|million|billion")
|
|
1652
|
+
WORDED = re.compile(
|
|
1653
|
+
rf"(?<![\w-])((?:{NUMBER_WORDS})(?:(?:[\s-]+|\s+and\s+)(?:{NUMBER_WORDS}))*)"
|
|
1654
|
+
r"(?:(?!\n)[\s-])+((?:[^\W\d_]|[%\u00b0])+)", re.IGNORECASE)
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
# @req> REQ-10839190@o1M0SkcZ_uZg sjs5bc
|
|
1658
|
+
def _unit_quantities(records, root):
|
|
1659
|
+
held = corpus.reachable(records).get(VALUE_TYPES, VALUE_TYPES)
|
|
1660
|
+
if corpus.kind_of(held, records.get(held)) != "data":
|
|
1661
|
+
return []
|
|
1662
|
+
units = _units(records)
|
|
1663
|
+
problems = []
|
|
1664
|
+
for uid, data in sorted(records.items()):
|
|
1665
|
+
if corpus.kind_of(uid, data) not in ("requirement", "guard"):
|
|
1666
|
+
continue
|
|
1667
|
+
# @req> REQ-74163593@shkbFgp8gpvT dzwaj3
|
|
1668
|
+
if data.get("status") in RETIRED:
|
|
1669
|
+
continue
|
|
1670
|
+
own = own_quantities(root, uid, data)
|
|
1671
|
+
criteria = own.get("acceptance_criteria")
|
|
1672
|
+
text = "\n".join([str(own.get("text") or "")] + [
|
|
1673
|
+
str(criterion.get(part) or "")
|
|
1674
|
+
for criterion in (criteria if isinstance(criteria, list) else [])
|
|
1675
|
+
if isinstance(criterion, dict) for part in ("given", "when", "then")])
|
|
1676
|
+
text = corpus.UID_IN_PROSE.sub(" ", corpus.PARAM_REF.sub(
|
|
1677
|
+
" ", corpus.CONCEPT_LINK.sub(r"\1", text)))
|
|
1678
|
+
written = sorted({f"{found.group(1)} {found.group(2)}"
|
|
1679
|
+
for pattern, prose in ((QUANTITY, _ungrouped(text)), (WORDED, text))
|
|
1680
|
+
for found in pattern.finditer(prose)
|
|
1681
|
+
if found.group(2).lower() in units})
|
|
1682
|
+
# @req> REQ-30898926@Vu28eWaG_1mE p4sy2b
|
|
1683
|
+
problems += [f"{uid}: writes {quantity} -- reference the parameter or "
|
|
1684
|
+
f"entry holding it, {corpus.NAME_FORM}" for quantity in written]
|
|
1685
|
+
return problems
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def _value_types(records, root):
|
|
1689
|
+
held = corpus.reachable(records).get(VALUE_TYPES, VALUE_TYPES)
|
|
1690
|
+
if corpus.kind_of(held, records.get(held)) != "data":
|
|
1691
|
+
return []
|
|
1692
|
+
listed = next((field.choices for field in _fields.of(root, "parameter")
|
|
1693
|
+
if field.name == "value_type"), ())
|
|
1694
|
+
# @req> REQ-34134685@7WjKv7L7yONz tr5vto
|
|
1695
|
+
# @req> REQ-30898926@Vu28eWaG_1mE bti7u2
|
|
1696
|
+
problems = [f"{uid}: entry {'.'.join(where)}: value_type "
|
|
1697
|
+
f"{fields['value_type']!r} is a "
|
|
1698
|
+
f"{'list' if isinstance(fields['value_type'], list) else 'mapping'} "
|
|
1699
|
+
"-- state one value type"
|
|
1700
|
+
for uid, data in sorted(records.items())
|
|
1701
|
+
if corpus.kind_of(uid, data) == "data"
|
|
1702
|
+
for where, fields in _entries_within(corpus.entries(data))
|
|
1703
|
+
if fields is not None
|
|
1704
|
+
and isinstance(fields.get("value_type"), (list, dict))]
|
|
1705
|
+
for key, stated in (corpus.entries(records[held]) or {}).items():
|
|
1706
|
+
# @req> REQ-50607767@HUHiB9K0Vm5f zfnoqs
|
|
1707
|
+
if str(key) not in listed:
|
|
1708
|
+
# @req> REQ-30898926@Vu28eWaG_1mE b3icej
|
|
1709
|
+
problems.append(f"{held}: entry {key} is no value type the kind schema "
|
|
1710
|
+
"for parameters lists -- remove it with --drop-entry, "
|
|
1711
|
+
"or add it to that schema's value_type by hand")
|
|
1712
|
+
# @req+ REQ-39867440@lNag_-a2yKhi jbq26t
|
|
1713
|
+
units = stated.get("units") if isinstance(stated, dict) else None
|
|
1714
|
+
units = (dict.fromkeys(map(str, units if isinstance(units, list) else [units]))
|
|
1715
|
+
if units is not None and not isinstance(units, dict) else units)
|
|
1716
|
+
for symbol, unit in (units.items() if isinstance(units, dict) else ()):
|
|
1717
|
+
bounds = ({key for key in ("factor", "least", "most") if unit.get(key) is not None}
|
|
1718
|
+
if isinstance(unit, dict) else set())
|
|
1719
|
+
if bounds not in ({"factor"}, {"least", "most"}):
|
|
1720
|
+
# @req> REQ-30898926@Vu28eWaG_1mE nq4wli
|
|
1721
|
+
problems.append(f"{held}: entry {key} unit {symbol} states "
|
|
1722
|
+
f"{', '.join(sorted(bounds)) or 'no bound'} -- state "
|
|
1723
|
+
"a factor, or a least with a most")
|
|
1724
|
+
# @req- jbq26t
|
|
1725
|
+
return problems
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _duplicate_names(records):
|
|
1729
|
+
# @req+ REQ-53177302@oSukBFyA3jXq uay7dt
|
|
1730
|
+
named = {}
|
|
1731
|
+
for uid, data in records.items():
|
|
1732
|
+
name = corpus.name_of(uid, data)
|
|
1733
|
+
if name is not None:
|
|
1734
|
+
named.setdefault(name, []).append(uid)
|
|
1735
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 2emtrk
|
|
1736
|
+
return [
|
|
1737
|
+
f"{' and '.join(sorted(uids))} share the name `{name}` -- "
|
|
1738
|
+
"a name addresses one item; rename all but one"
|
|
1739
|
+
for name, uids in sorted(named.items())
|
|
1740
|
+
if len(uids) > 1
|
|
1741
|
+
]
|
|
1742
|
+
# @req- uay7dt
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
ONE_WAY = (
|
|
1746
|
+
("derives_from", "none of these can be the one the others derive from"),
|
|
1747
|
+
("depends_on", "none of these can be built before the others"),
|
|
1748
|
+
("supersedes", "none of these can be the one the others replace"),
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def cycles(records):
|
|
1753
|
+
problems = []
|
|
1754
|
+
# @req> REQ-44520277@w92ngdeF87Mm 2pebps
|
|
1755
|
+
for relation, tail in ONE_WAY:
|
|
1756
|
+
edges = {
|
|
1757
|
+
uid: sorted(
|
|
1758
|
+
target
|
|
1759
|
+
for target, stated in corpus.mapping(data, "relations").items()
|
|
1760
|
+
if stated == relation
|
|
1761
|
+
)
|
|
1762
|
+
for uid, data in records.items()
|
|
1763
|
+
}
|
|
1764
|
+
# @req> REQ-30898926@Vu28eWaG_1mE frqcqo
|
|
1765
|
+
problems += _cycle_problems(edges, relation, tail,
|
|
1766
|
+
"`reqctl unrelate` one link of the cycle")
|
|
1767
|
+
return problems
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def _strays(root, paths):
|
|
1771
|
+
corpus_dir = Path(root) / "requirements"
|
|
1772
|
+
claimed = set(paths)
|
|
1773
|
+
skipped = (corpus_dir / "schemas", corpus_dir / "baselines")
|
|
1774
|
+
problems = []
|
|
1775
|
+
# @req> REQ-69003763@oacx0bF0kxdV 4ljfwh
|
|
1776
|
+
for held in sorted(corpus_dir.rglob("*.yml")) + sorted(corpus_dir.rglob("*.yaml")):
|
|
1777
|
+
# @req> REQ-61926693@GZyk9i4NErF6 snr2ig
|
|
1778
|
+
if held in claimed or held in (_baseline.path(root),
|
|
1779
|
+
_settings.path(root), Path(root) / _uncited.RECORD):
|
|
1780
|
+
continue
|
|
1781
|
+
if any(skip in held.parents for skip in skipped):
|
|
1782
|
+
continue
|
|
1783
|
+
# @req> REQ-30898926@Vu28eWaG_1mE a7qobc
|
|
1784
|
+
problems.append(
|
|
1785
|
+
f"{held.relative_to(root)}: not where an item lives -- an item is "
|
|
1786
|
+
"requirements/(reqs|params|terms|data)/UID.yml; move or delete it"
|
|
1787
|
+
)
|
|
1788
|
+
return problems
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
# @req> REQ-61926693@GZyk9i4NErF6 cf76cn
|
|
1792
|
+
def _stated(root):
|
|
1793
|
+
where = _settings.path(root)
|
|
1794
|
+
if not where.is_file():
|
|
1795
|
+
return []
|
|
1796
|
+
held = corpus.loads(corpus.read_text(where), _settings.SETTINGS)
|
|
1797
|
+
return [f"{_settings.SETTINGS}: {fault}" for fault in _settings.faults(held)]
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
UNFINISHED = "reqctl could not finish"
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _guarded(where, check, *args):
|
|
1804
|
+
# @req+ REQ-23691260@3s5Nt8Pn1BXA kgrg4l
|
|
1805
|
+
try:
|
|
1806
|
+
return check(*args)
|
|
1807
|
+
except corpus.ReqctlError as fault:
|
|
1808
|
+
return [f"{where}: {fault}"]
|
|
1809
|
+
# @req> REQ-55939360@qvRmeRuBl0T9 yi53ne
|
|
1810
|
+
except Exception as fault:
|
|
1811
|
+
return [f"{where}: {UNFINISHED} {check.__name__} -- "
|
|
1812
|
+
f"{type(fault).__name__}: {fault}"]
|
|
1813
|
+
# @req- kgrg4l
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def _settled(path):
|
|
1817
|
+
text = corpus.read_text(path)
|
|
1818
|
+
# @req> REQ-98359235@ZY1HJ0hcSyAZ 5wu7pz
|
|
1819
|
+
if corpus.CONFLICTED.search(text):
|
|
1820
|
+
# @req> REQ-30898926@Vu28eWaG_1mE kriku5
|
|
1821
|
+
raise corpus.ReqctlError(
|
|
1822
|
+
f"holds an unresolved merge -- `reqctl resolve {path.stem}` "
|
|
1823
|
+
"settles one that falls within its pins"
|
|
1824
|
+
)
|
|
1825
|
+
# @req> REQ-49870454@KKNV-vdY6oM1 ogeofb
|
|
1826
|
+
data = corpus.loads(text, path, aliases=False)
|
|
1827
|
+
if isinstance(data, dict):
|
|
1828
|
+
corpus.refuse_misshapen(path, data)
|
|
1829
|
+
return data
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
# @req> REQ-21699310@HJWvkB2QKjez uv4bc5
|
|
1833
|
+
def _bound_guards(records, root):
|
|
1834
|
+
nominated = corpus.binding_dimensions(root)
|
|
1835
|
+
# @req> REQ-30898926@Vu28eWaG_1mE iccei7
|
|
1836
|
+
return [
|
|
1837
|
+
f"{uid}: references {address}, which binds the guard to {name} -- "
|
|
1838
|
+
"reword the guard without the reference, or state the rule as a "
|
|
1839
|
+
"requirement"
|
|
1840
|
+
for uid, data in sorted(records.items())
|
|
1841
|
+
if corpus.kind_of(uid, data) == "guard"
|
|
1842
|
+
for address in corpus.references(data) + corpus.concept_references(data)
|
|
1843
|
+
for name in [corpus.split_address(address)[0]]
|
|
1844
|
+
if name in nominated]
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def _named_schemas(records, root):
|
|
1848
|
+
# @req+ REQ-48136849@EXeydTy0CfsX wljy47
|
|
1849
|
+
held = {corpus.name_of(uid, data) for uid, data in records.items()}
|
|
1850
|
+
# @req> REQ-30898926@Vu28eWaG_1mE klvbx7
|
|
1851
|
+
return [f"corpus: {name}.schema.yaml governs an item named {name}, and "
|
|
1852
|
+
"the corpus holds none -- mint it, or remove the schema"
|
|
1853
|
+
for name in corpus.named_schemas(root) if name not in held]
|
|
1854
|
+
# @req- wljy47
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def run(root, exempt=None):
|
|
1858
|
+
paths = corpus.item_files(root)
|
|
1859
|
+
problems, broken_schemas = _schemas_are_schemas(root)
|
|
1860
|
+
records, unreadable = {}, {}
|
|
1861
|
+
for path in paths:
|
|
1862
|
+
# @req+ REQ-69525887@WpuY4yv5NYDo d3uvus
|
|
1863
|
+
try:
|
|
1864
|
+
data = _settled(path)
|
|
1865
|
+
except corpus.ReqctlError as error:
|
|
1866
|
+
unreadable[path.stem] = str(error).replace(f"{root}/", "")
|
|
1867
|
+
continue
|
|
1868
|
+
# @req- d3uvus
|
|
1869
|
+
if isinstance(data, dict):
|
|
1870
|
+
records[path.stem] = data
|
|
1871
|
+
reachable = corpus.reachable(records)
|
|
1872
|
+
known = {path.stem for path in paths} | set(reachable)
|
|
1873
|
+
past = history.History(root)
|
|
1874
|
+
|
|
1875
|
+
for path in paths:
|
|
1876
|
+
uid = path.stem
|
|
1877
|
+
if uid not in records:
|
|
1878
|
+
problems += _guarded(uid, _identity, root, path, None)
|
|
1879
|
+
# @req> REQ-30898926@Vu28eWaG_1mE haoc6o
|
|
1880
|
+
problems.append(f"{uid}: " + unreadable.get(
|
|
1881
|
+
uid, "item file is not a mapping -- edit it by hand into a "
|
|
1882
|
+
"mapping of the item's fields"))
|
|
1883
|
+
continue
|
|
1884
|
+
problems += item_problems(root, uid, path, records[uid], records,
|
|
1885
|
+
reachable, known, broken_schemas, suspect=past)
|
|
1886
|
+
return problems + corpus_checks(records, root, paths, exempt)
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def item_problems(root, uid, path, data, records, reachable, known, broken=(), *,
|
|
1890
|
+
suspect):
|
|
1891
|
+
# @req+ REQ-42390302@QOC4GhS7SkFg igyueu
|
|
1892
|
+
problems = _guarded(uid, _identity, root, path, data)
|
|
1893
|
+
try:
|
|
1894
|
+
needs = corpus.schema_name_at(root, corpus.kind_of(uid, data),
|
|
1895
|
+
corpus.name_of(uid, data))
|
|
1896
|
+
except (corpus.ReqctlError, TypeError):
|
|
1897
|
+
needs = None
|
|
1898
|
+
if needs not in broken:
|
|
1899
|
+
problems += _guarded(uid, schema_problems, root, uid, data)
|
|
1900
|
+
problems += _guarded(uid, ears, uid, data)
|
|
1901
|
+
problems += _guarded(uid, text_values, uid, data)
|
|
1902
|
+
problems += _guarded(uid, repeated_labels, uid, data)
|
|
1903
|
+
problems += _guarded(uid, hidden_values, uid, data)
|
|
1904
|
+
problems += _guarded(uid, dictionary_rules, uid, data)
|
|
1905
|
+
problems += _guarded(uid, listed_fields, root, uid, data)
|
|
1906
|
+
problems += _guarded(uid, _relations, uid, data, records, reachable, known)
|
|
1907
|
+
problems += _guarded(uid, _references, uid, data, known, root)
|
|
1908
|
+
problems += _guarded(uid, _assessed, uid, data, records, reachable, known, suspect)
|
|
1909
|
+
return problems
|
|
1910
|
+
# @req- igyueu
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def corpus_checks(records, root, paths, exempt=None):
|
|
1914
|
+
problems = _guarded("corpus", _strays, root, paths)
|
|
1915
|
+
problems += _guarded("corpus", _stated, root)
|
|
1916
|
+
problems += _guarded("corpus", _named_schemas, records, root)
|
|
1917
|
+
problems += _guarded("corpus", _bound_guards, records, root)
|
|
1918
|
+
problems += _guarded("corpus", _unlinked_terms, records, root)
|
|
1919
|
+
problems += _guarded("corpus", shared_quantities, records, root)
|
|
1920
|
+
problems += _guarded("corpus", approved_relations, records)
|
|
1921
|
+
problems += _guarded("corpus", conflicts, records)
|
|
1922
|
+
problems += _guarded("corpus", supersedes, records)
|
|
1923
|
+
# @req> REQ-23691260@3s5Nt8Pn1BXA oan2d2
|
|
1924
|
+
problems += coherence(records, root,
|
|
1925
|
+
lambda check, *args: _guarded("corpus", check, *args))
|
|
1926
|
+
problems += _guarded("corpus", cycles, records)
|
|
1927
|
+
if exempt is not None:
|
|
1928
|
+
problems += _guarded("corpus", exempted, records, root, exempt)
|
|
1929
|
+
return problems
|