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/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