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/fields.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import copy
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from . import corpus
|
|
7
|
+
from .corpus import ReqctlError
|
|
8
|
+
|
|
9
|
+
KIND = "kind"
|
|
10
|
+
ENTRIES = "entries"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def boolean(text):
|
|
14
|
+
if text not in ("true", "false"):
|
|
15
|
+
# @req> REQ-30898926@Vu28eWaG_1mE kyzn7q
|
|
16
|
+
raise argparse.ArgumentTypeError(
|
|
17
|
+
f"{text!r} is not a boolean -- pass true or false")
|
|
18
|
+
return text == "true"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CONVERTED = {"integer": int, "number": float, "boolean": boolean}
|
|
22
|
+
PLAIN = ("string", *CONVERTED)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _dest(flag):
|
|
26
|
+
return flag[2:].replace("-", "_")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _flag(word):
|
|
30
|
+
return "--" + word.replace("_", "-")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Flag:
|
|
35
|
+
flag: str
|
|
36
|
+
choices: tuple
|
|
37
|
+
repeated: bool
|
|
38
|
+
switch: bool
|
|
39
|
+
convert: object
|
|
40
|
+
metavar: str | None
|
|
41
|
+
entries: bool
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def dest(self):
|
|
45
|
+
return _dest(self.flag)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Field:
|
|
50
|
+
name: str
|
|
51
|
+
flag: str
|
|
52
|
+
choices: tuple
|
|
53
|
+
repeated: bool
|
|
54
|
+
required: bool
|
|
55
|
+
clears: str | None
|
|
56
|
+
drops: str | None
|
|
57
|
+
empty: object
|
|
58
|
+
address: bool
|
|
59
|
+
entry: bool
|
|
60
|
+
textual: bool
|
|
61
|
+
prose: bool
|
|
62
|
+
convert: object
|
|
63
|
+
parts: tuple
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def dest(self):
|
|
67
|
+
return _dest(self.flag)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def cleared(self):
|
|
71
|
+
return _dest(self.clears) if self.clears else None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def dropped(self):
|
|
75
|
+
return _dest(self.drops) if self.drops else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def root():
|
|
79
|
+
try:
|
|
80
|
+
return corpus.find_root()
|
|
81
|
+
except ReqctlError:
|
|
82
|
+
return "."
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _schema(root, kind):
|
|
86
|
+
held = corpus.schema_for(root, kind)
|
|
87
|
+
return held if isinstance(held, dict) else {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pointed(schema, ref):
|
|
91
|
+
if not ref.startswith("#/"):
|
|
92
|
+
return None
|
|
93
|
+
target = schema
|
|
94
|
+
for step in ref[2:].split("/"):
|
|
95
|
+
step = step.replace("~1", "/").replace("~0", "~")
|
|
96
|
+
if isinstance(target, list) and step.isdecimal() and int(step) < len(target):
|
|
97
|
+
target = target[int(step)]
|
|
98
|
+
else:
|
|
99
|
+
target = target.get(step) if isinstance(target, dict) else None
|
|
100
|
+
return target
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolved(schema, node):
|
|
104
|
+
if not isinstance(node, dict):
|
|
105
|
+
return {}
|
|
106
|
+
named = schema.get("$id", "a kind schema")
|
|
107
|
+
seen = set()
|
|
108
|
+
while "$ref" in node:
|
|
109
|
+
ref = node["$ref"]
|
|
110
|
+
if ref in seen:
|
|
111
|
+
# @req> REQ-30898926@Vu28eWaG_1mE njzgy6
|
|
112
|
+
raise ReqctlError(f"{named}: {ref} refers back to itself -- break the "
|
|
113
|
+
"self-reference in the schema")
|
|
114
|
+
target = _pointed(schema, ref)
|
|
115
|
+
if isinstance(target, bool):
|
|
116
|
+
target = {} if target else {"not": {}}
|
|
117
|
+
if not isinstance(target, dict):
|
|
118
|
+
# @req> REQ-30898926@Vu28eWaG_1mE uzmekp
|
|
119
|
+
raise ReqctlError(f"{named}: {ref} names nothing within the schema -- "
|
|
120
|
+
"point it at a property the schema holds")
|
|
121
|
+
seen.add(ref)
|
|
122
|
+
node = {**target, **{key: value for key, value in node.items()
|
|
123
|
+
if key != "$ref"}}
|
|
124
|
+
return node
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _forbidden(schema, stated):
|
|
128
|
+
return stated is False or _resolved(schema, stated).get("not") in ({}, True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _selects(schema, test, kind):
|
|
132
|
+
if set(test) - {"properties", "required"}:
|
|
133
|
+
return False
|
|
134
|
+
if set(test.get("required", ())) - {KIND}:
|
|
135
|
+
return False
|
|
136
|
+
tested = test.get("properties")
|
|
137
|
+
if not isinstance(tested, dict) or set(tested) != {KIND}:
|
|
138
|
+
return False
|
|
139
|
+
said = _resolved(schema, tested[KIND])
|
|
140
|
+
return said.get("const") == kind or kind in said.get("enum", ())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _merged(schema, kind):
|
|
144
|
+
properties, required, barred = {}, set(), set()
|
|
145
|
+
branches = [_resolved(schema, item) for item in schema.get("allOf", ())]
|
|
146
|
+
chosen = [_resolved(schema, branch.get("then")) for branch in branches
|
|
147
|
+
if _selects(schema, _resolved(schema, branch.get("if")), kind)]
|
|
148
|
+
for block in [schema] + branches + chosen:
|
|
149
|
+
required.update(block.get("required", ()))
|
|
150
|
+
for name, stated in (block.get("properties") or {}).items():
|
|
151
|
+
if _forbidden(schema, stated):
|
|
152
|
+
barred.add(name)
|
|
153
|
+
else:
|
|
154
|
+
properties[name] = {**properties.get(name, {}),
|
|
155
|
+
**_resolved(schema, stated)}
|
|
156
|
+
return {name: stated for name, stated in properties.items()
|
|
157
|
+
if name not in barred}, required
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _prose(node):
|
|
161
|
+
if isinstance(node, dict):
|
|
162
|
+
return node.get("x-prose") is True or any(map(_prose, node.values()))
|
|
163
|
+
if isinstance(node, list):
|
|
164
|
+
return any(map(_prose, node))
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _field(schema, name, stated, required, entry):
|
|
169
|
+
said = stated.get("x-flag", name)
|
|
170
|
+
if not isinstance(said, str) or not corpus.DATA_KEY.fullmatch(said):
|
|
171
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 4syjdu
|
|
172
|
+
raise ReqctlError(f"{schema.get('$id', 'a kind schema')}: {name} would "
|
|
173
|
+
f"take the flag {said!r}, which is not a snake_case word -- "
|
|
174
|
+
"give the field a snake_case x-flag")
|
|
175
|
+
shape = stated.get("type")
|
|
176
|
+
repeated = shape in ("array", "object")
|
|
177
|
+
items = _resolved(schema, stated.get("items", {})) if shape == "array" else {}
|
|
178
|
+
typed = items.get("type") if shape == "array" else shape
|
|
179
|
+
convert = CONVERTED.get(typed) if isinstance(typed, str) else None
|
|
180
|
+
parts = tuple(items.get("required", ())) if items.get("type") == "object" else ()
|
|
181
|
+
values = (_resolved(schema, stated.get("additionalProperties", {}))
|
|
182
|
+
if shape == "object" else {})
|
|
183
|
+
drops = _flag("drop_" + said) if values.get("type") in PLAIN else None
|
|
184
|
+
enum = stated.get("enum", ())
|
|
185
|
+
choices = tuple(enum) if all(isinstance(one, str) for one in enum) else ()
|
|
186
|
+
clears, empty = None, None
|
|
187
|
+
if not required:
|
|
188
|
+
clears = _flag("no_" + said)
|
|
189
|
+
elif repeated and not stated.get("minItems") and not stated.get("minProperties"):
|
|
190
|
+
clears, empty = _flag("no_" + said), list if shape == "array" else dict
|
|
191
|
+
return Field(name=name, flag=_flag(said), choices=choices,
|
|
192
|
+
repeated=repeated,
|
|
193
|
+
required=required and (entry or "default" not in stated),
|
|
194
|
+
clears=clears, drops=drops, empty=empty,
|
|
195
|
+
address=stated.get("x-address") is True, entry=entry,
|
|
196
|
+
textual=(convert is None and "enum" not in stated
|
|
197
|
+
and shape != "object" and not parts),
|
|
198
|
+
prose=_prose(stated), convert=convert, parts=parts)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def of(root, kind):
|
|
202
|
+
schema = _schema(root, kind)
|
|
203
|
+
properties, required = _merged(schema, kind)
|
|
204
|
+
held = []
|
|
205
|
+
for name, stated in properties.items():
|
|
206
|
+
# @req> REQ-54521322@g8kfVhS7XXWx 3acfqk
|
|
207
|
+
if stated.get("readOnly") is True:
|
|
208
|
+
continue
|
|
209
|
+
if name == ENTRIES and stated.get("maxProperties") == 1:
|
|
210
|
+
entry = _resolved(schema, stated.get("additionalProperties", {}))
|
|
211
|
+
wanted = set(entry.get("required", ()))
|
|
212
|
+
# @req> REQ-54521322@g8kfVhS7XXWx rd4w24
|
|
213
|
+
held += [_field(schema, key, _resolved(schema, value), key in wanted, True)
|
|
214
|
+
for key, value in (entry.get("properties") or {}).items()
|
|
215
|
+
if not _forbidden(schema, value)
|
|
216
|
+
and _resolved(schema, value).get("readOnly") is not True]
|
|
217
|
+
continue
|
|
218
|
+
held.append(_field(schema, name, stated, name in required, False))
|
|
219
|
+
taken = {}
|
|
220
|
+
for field in held:
|
|
221
|
+
for flag in filter(None, (field.flag, field.clears, field.drops)):
|
|
222
|
+
if flag in taken:
|
|
223
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 3xifyq
|
|
224
|
+
raise ReqctlError(f"{kind}: {taken[flag]} and {field.name} would "
|
|
225
|
+
f"both take {flag}; give one an x-flag")
|
|
226
|
+
taken[flag] = field.name
|
|
227
|
+
return held
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def names(root, kind):
|
|
231
|
+
return set(_merged(_schema(root, kind), kind)[0])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# @req> REQ-41779958@i5ZjNQdrVe4B 4aox7m
|
|
235
|
+
def defaults(root, kind):
|
|
236
|
+
properties, _ = _merged(_schema(root, kind), kind)
|
|
237
|
+
held = {name: copy.deepcopy(stated["default"])
|
|
238
|
+
for name, stated in properties.items() if "default" in stated}
|
|
239
|
+
if KIND in properties:
|
|
240
|
+
held[KIND] = kind
|
|
241
|
+
return held
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def flags(root):
|
|
245
|
+
held = {}
|
|
246
|
+
for kind in corpus.SCHEMA_NAMES:
|
|
247
|
+
for field in of(root, kind):
|
|
248
|
+
stated = held.get(field.flag, field)
|
|
249
|
+
if (stated.repeated, stated.convert) != (field.repeated, field.convert):
|
|
250
|
+
# @req> REQ-30898926@Vu28eWaG_1mE me6yhq
|
|
251
|
+
raise ReqctlError(f"{field.flag} takes a different kind of value "
|
|
252
|
+
f"for a {kind}; give one field an x-flag")
|
|
253
|
+
choices = (tuple(dict.fromkeys(stated.choices + field.choices))
|
|
254
|
+
if stated.choices and field.choices else ())
|
|
255
|
+
metavar = f"'{' | '.join(field.parts).upper()}'" if field.parts else None
|
|
256
|
+
held[field.flag] = Flag(field.flag, choices, field.repeated, False,
|
|
257
|
+
field.convert, metavar, field.name == ENTRIES)
|
|
258
|
+
return list(held.values())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def clears(root):
|
|
262
|
+
held = {}
|
|
263
|
+
for kind in corpus.SCHEMA_NAMES:
|
|
264
|
+
for field in of(root, kind):
|
|
265
|
+
if field.clears:
|
|
266
|
+
held[field.clears] = Flag(field.clears, (), False, True, None, None, False)
|
|
267
|
+
if field.drops:
|
|
268
|
+
held[field.drops] = Flag(field.drops, (), True, False, None, "KEY", False)
|
|
269
|
+
return list(held.values())
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# @req+ REQ-14895892@JjrTwHoJqTRe aepkss
|
|
273
|
+
def _expanded(schema, nodes, kind):
|
|
274
|
+
seen = set()
|
|
275
|
+
|
|
276
|
+
def facets(node, read, sure):
|
|
277
|
+
if (id(node), read, sure) in seen or not isinstance(node, dict):
|
|
278
|
+
return
|
|
279
|
+
seen.add((id(node), read, sure))
|
|
280
|
+
if "$ref" in node:
|
|
281
|
+
yield from facets(_resolved(schema, {"$ref": node["$ref"]}), read, sure)
|
|
282
|
+
yield node, read, sure
|
|
283
|
+
for key in ("allOf", "anyOf", "oneOf"):
|
|
284
|
+
for member in node.get(key, ()):
|
|
285
|
+
yield from facets(member, read and key == "allOf", sure and key == "allOf")
|
|
286
|
+
if "if" in node:
|
|
287
|
+
kinds = {one for one in corpus.SCHEMA_NAMES
|
|
288
|
+
if _selects(schema, _resolved(schema, node["if"]), one)}
|
|
289
|
+
for branch, applies in (("then", not kinds or kind in kinds),
|
|
290
|
+
("else", kind not in kinds)):
|
|
291
|
+
if applies:
|
|
292
|
+
yield from facets(node.get(branch), read, sure and bool(kinds))
|
|
293
|
+
|
|
294
|
+
return [one for node, read, sure in nodes for one in facets(node, read, sure)]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _children(schema, facets, name):
|
|
298
|
+
found = []
|
|
299
|
+
for node, read, sure in facets:
|
|
300
|
+
named = node.get("properties") or {}
|
|
301
|
+
if name in named:
|
|
302
|
+
found.append((named[name], read, sure))
|
|
303
|
+
found += [(value, read, sure) for pattern, value in
|
|
304
|
+
(node.get("patternProperties") or {}).items()
|
|
305
|
+
if isinstance(pattern, str) and re.search(pattern, name)]
|
|
306
|
+
return [] if any(sure and _forbidden(schema, value) for value, _, sure in found) else found
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _lacking(shipped, copy, held, stated, at, kind, seen=frozenset()):
|
|
310
|
+
pair = tuple(frozenset((id(node), read, sure) for node, read, sure in nodes)
|
|
311
|
+
for nodes in (held, stated))
|
|
312
|
+
if pair in seen or not (held and stated):
|
|
313
|
+
return
|
|
314
|
+
seen |= {pair}
|
|
315
|
+
carried = {marker for node, read, _ in stated if read for marker in _resolved(copy, node)}
|
|
316
|
+
yield from (f"{marker} on {at}" for node, read, _ in held if read
|
|
317
|
+
for marker in _resolved(shipped, node).keys() - carried
|
|
318
|
+
if marker.startswith("x-") or marker in ("default", "readOnly"))
|
|
319
|
+
held, stated = _expanded(shipped, held, kind), _expanded(copy, stated, kind)
|
|
320
|
+
for name in {name for node, _, _ in held for name in node.get("properties") or {}}:
|
|
321
|
+
yield from _lacking(shipped, copy, _children(shipped, held, name),
|
|
322
|
+
_children(copy, stated, name),
|
|
323
|
+
f"{at}.{name}" if at else name, kind, seen)
|
|
324
|
+
for step in ("items", "additionalProperties"):
|
|
325
|
+
yield from _lacking(shipped, copy, *([(node[step], read, sure) for node, read, sure in facets
|
|
326
|
+
if isinstance(node.get(step), dict)]
|
|
327
|
+
+ [(value, read, False) for node, read, _ in facets
|
|
328
|
+
if step == "additionalProperties"
|
|
329
|
+
for value in
|
|
330
|
+
(node.get("patternProperties") or {}).values()]
|
|
331
|
+
for facets in (held, stated)), at, kind, seen)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _lacking_plain(shipped, copy, held, stated, at, seen=frozenset()):
|
|
335
|
+
pair = (id(held), id(stated))
|
|
336
|
+
if pair in seen:
|
|
337
|
+
return
|
|
338
|
+
seen |= {pair}
|
|
339
|
+
held, stated = _resolved(shipped, held), _resolved(copy, stated)
|
|
340
|
+
yield from (f"{marker} on {at}" for marker in held.keys() - stated.keys()
|
|
341
|
+
if marker.startswith("x-") or marker in ("default", "readOnly"))
|
|
342
|
+
inner = stated.get("properties") or {}
|
|
343
|
+
for name, value in (held.get("properties") or {}).items():
|
|
344
|
+
if name in inner and not _forbidden(copy, inner[name]):
|
|
345
|
+
yield from _lacking_plain(shipped, copy, value, inner[name], f"{at}.{name}", seen)
|
|
346
|
+
for step in ("items", "additionalProperties"):
|
|
347
|
+
if isinstance(held.get(step), dict) and isinstance(stated.get(step), dict):
|
|
348
|
+
yield from _lacking_plain(shipped, copy, held[step], stated[step], at, seen)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def unmarked(shipped, copy, kinds):
|
|
352
|
+
found = set()
|
|
353
|
+
for kind in kinds:
|
|
354
|
+
found.update(_lacking(shipped, copy, [(shipped, True, True)], [(copy, True, True)],
|
|
355
|
+
"", kind))
|
|
356
|
+
held = _merged(shipped, kind)[0]
|
|
357
|
+
stated = _merged(copy if isinstance(copy, dict) else {}, kind)[0]
|
|
358
|
+
for name in held.keys() & stated.keys():
|
|
359
|
+
found.update(_lacking_plain(shipped, copy, held[name], stated[name], name))
|
|
360
|
+
return ", ".join(sorted(found))
|
|
361
|
+
# @req- aepkss
|
reqctl/graph.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from . import cite, corpus, history, uncited
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def unstamped(uid, path):
|
|
7
|
+
return (f"{uid}: {path} cites it without the stamp it was written "
|
|
8
|
+
"against -- cite it again with reqctl tag")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def listing(tree, citations, uid=None):
|
|
12
|
+
named = {(citation["uid"], citation["id"]) for citation in citations}
|
|
13
|
+
# @req> REQ-77594104@x3D--4236Dno 7nucmz
|
|
14
|
+
listed = {str(item.uid): corpus.mapping(item.data, corpus.CITATION_LIST)
|
|
15
|
+
for item in corpus.items(tree)
|
|
16
|
+
if corpus.citable(item)}
|
|
17
|
+
problems = []
|
|
18
|
+
# @req> REQ-13384695@P9NXsZKZp683 3lmvqm
|
|
19
|
+
for owner, held in listed.items():
|
|
20
|
+
if uid and owner != uid:
|
|
21
|
+
continue
|
|
22
|
+
for identity in sorted(i for i in held if (owner, i) not in named):
|
|
23
|
+
problems.append(
|
|
24
|
+
f"{owner}: its citation list holds {identity}, which no "
|
|
25
|
+
f"statement citation names together with {owner} -- "
|
|
26
|
+
f"reqctl unlist {owner} {identity}")
|
|
27
|
+
for citation in citations:
|
|
28
|
+
owner, identity = citation["uid"], citation["id"]
|
|
29
|
+
if (uid and owner != uid) or owner not in listed:
|
|
30
|
+
continue
|
|
31
|
+
# @req+ REQ-67655319@ezp6TxUzJ72E wqruf3
|
|
32
|
+
# @req+ REQ-32191310@aZkvQ6hvIwPv pmll7c
|
|
33
|
+
if identity not in listed[owner]:
|
|
34
|
+
problems.append(
|
|
35
|
+
f"{citation['path']}: citation {identity} of {owner} is not in "
|
|
36
|
+
f"its citation list -- read it against {owner}, then reqctl "
|
|
37
|
+
f"repin {identity}")
|
|
38
|
+
elif listed[owner][identity] != citation["digest"]:
|
|
39
|
+
problems.append(
|
|
40
|
+
f"{citation['path']}: citation {identity} of {owner} covers "
|
|
41
|
+
"lines that changed since its citation list took their digest "
|
|
42
|
+
f"-- read them against {owner}, then reqctl repin {identity}")
|
|
43
|
+
# @req- pmll7c
|
|
44
|
+
# @req- wqruf3
|
|
45
|
+
return problems
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# @req> REQ-40478019@_dwqsOpSOQXA bkbrwo
|
|
49
|
+
def whole(tree, tags, root=None, uid=None):
|
|
50
|
+
problems = []
|
|
51
|
+
for item in corpus.items(tree):
|
|
52
|
+
owner = str(item.uid)
|
|
53
|
+
if not corpus.citable(item):
|
|
54
|
+
continue
|
|
55
|
+
for path, held in corpus.mapping(item.data, corpus.WHOLE_FILES).items():
|
|
56
|
+
held = held if isinstance(held, dict) else {}
|
|
57
|
+
# @req> REQ-19896380@lvCPXiVYZ6ud lbb72g
|
|
58
|
+
if not corpus.is_test(path):
|
|
59
|
+
tags.setdefault(owner, []).append((path, held.get("pinned") or "", None))
|
|
60
|
+
if root is None or (uid and owner != uid):
|
|
61
|
+
continue
|
|
62
|
+
# @req> REQ-80254000@aV5K4B7jbtHD sb54ru
|
|
63
|
+
if corpus.is_test(path):
|
|
64
|
+
problems.append(f"{path}: the whole-file citation {owner} holds is of "
|
|
65
|
+
f"a test -- reqctl untag {path} --req {owner}")
|
|
66
|
+
continue
|
|
67
|
+
target = (Path(root) / path).resolve()
|
|
68
|
+
if not target.is_relative_to(Path(root).resolve()):
|
|
69
|
+
# @req> REQ-30898926@Vu28eWaG_1mE plek4e
|
|
70
|
+
problems.append(f"{owner}: it holds a whole-file citation of {path}, "
|
|
71
|
+
"which is not a file of the code base -- edit "
|
|
72
|
+
f"{owner}'s whole_file_citations by hand to "
|
|
73
|
+
f"drop {path}")
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
text = target.read_bytes().decode()
|
|
77
|
+
except (OSError, UnicodeDecodeError):
|
|
78
|
+
# @req> REQ-30898926@Vu28eWaG_1mE kf3fxw
|
|
79
|
+
problems.append(f"{owner}: it holds a whole-file citation of {path}, "
|
|
80
|
+
"which does not read -- make the file readable "
|
|
81
|
+
f"(fix its permissions), or reqctl untag {path} "
|
|
82
|
+
f"--req {owner}")
|
|
83
|
+
continue
|
|
84
|
+
if held.get("digest") != cite.whole(path, text):
|
|
85
|
+
problems.append(
|
|
86
|
+
f"{path}: the whole-file citation {owner} holds covers a file "
|
|
87
|
+
f"that changed since it took its digest -- read it against "
|
|
88
|
+
f"{owner}, then reqctl tag {path} --req {owner}")
|
|
89
|
+
return problems
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def trace(tree, root, uid=None, base=None, record=False):
|
|
93
|
+
citations, cited_problems = cite.read(root, digested=True, uid=uid)
|
|
94
|
+
# @req> REQ-21635723@CvxpeHCRTgGz qwvqiw
|
|
95
|
+
if record:
|
|
96
|
+
uncited.record(tree, root, citations)
|
|
97
|
+
# @req+ REQ-19896380@lvCPXiVYZ6ud 6rtw6m
|
|
98
|
+
shown = [c for c in citations if not corpus.is_test(c["path"])]
|
|
99
|
+
tags = cite.cited(shown)
|
|
100
|
+
# @req- 6rtw6m
|
|
101
|
+
# @req> REQ-40478019@_dwqsOpSOQXA rvd6f2
|
|
102
|
+
cited_problems += whole(tree, tags, root, uid)
|
|
103
|
+
# @req> REQ-27534660@OcqGUyoB-NOR bc223j
|
|
104
|
+
# @req> REQ-64570886@Zckz3LzHUzXn fv2hvw
|
|
105
|
+
if not uid:
|
|
106
|
+
cited_problems += uncited.problems(tree, root, citations, base)
|
|
107
|
+
# @req> REQ-76962559@NU0P43-PPSGC dyday2
|
|
108
|
+
if uid:
|
|
109
|
+
corpus.find(tree, uid)
|
|
110
|
+
known = {str(item.uid) for item in corpus.items(tree)}
|
|
111
|
+
past = history.History(root)
|
|
112
|
+
|
|
113
|
+
rows, problems, unimplemented, stale, deprecated = [], list(cited_problems), [], [], []
|
|
114
|
+
# @req> REQ-19896380@lvCPXiVYZ6ud aes6o5
|
|
115
|
+
# @req> REQ-52825760@f9HMlzmiwhyn 5aendb
|
|
116
|
+
for citation in citations:
|
|
117
|
+
if corpus.is_test(citation["path"]) and (not uid or citation["uid"] == uid):
|
|
118
|
+
problems.append(
|
|
119
|
+
f"{citation['path']}: citation {citation['id']} of "
|
|
120
|
+
f"{citation['uid']} is in a test -- reqctl untag {citation['id']}")
|
|
121
|
+
for item in corpus.items(tree):
|
|
122
|
+
current = str(item.uid)
|
|
123
|
+
# @req> REQ-64570886@Zckz3LzHUzXn jzenja
|
|
124
|
+
if uid and current != uid:
|
|
125
|
+
continue
|
|
126
|
+
data = corpus.raw(item)
|
|
127
|
+
cited = tags.get(current, [])
|
|
128
|
+
files = list(dict.fromkeys(path for path, _, _ in cited))
|
|
129
|
+
rows.append(
|
|
130
|
+
{
|
|
131
|
+
"uid": current,
|
|
132
|
+
"status": data.get("status"),
|
|
133
|
+
"implementation": files,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
if files and not corpus.citable(item):
|
|
137
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 4urczb
|
|
138
|
+
problems.append(
|
|
139
|
+
f"{current}: referenced by {', '.join(files)} -- only a "
|
|
140
|
+
"requirement, a guard, a parameter or a data item is cited "
|
|
141
|
+
f"-- reqctl untag each citation of {current}, then reqctl tag "
|
|
142
|
+
"the requirement stated against it"
|
|
143
|
+
)
|
|
144
|
+
# @req> REQ-35979865@pd7CVJ7isMys iz2v2g
|
|
145
|
+
# @req> REQ-77594104@x3D--4236Dno tizjds
|
|
146
|
+
if (corpus.citable(item)
|
|
147
|
+
and data.get("status") == "deprecated" and cited):
|
|
148
|
+
deprecated += [{"uid": current, "path": path} for path
|
|
149
|
+
in dict.fromkeys(p for p, pinned, _ in cited if pinned)]
|
|
150
|
+
problems += [unstamped(current, path)
|
|
151
|
+
for path, pinned, _ in cited if not pinned]
|
|
152
|
+
continue
|
|
153
|
+
if data.get("status") != "approved":
|
|
154
|
+
if files:
|
|
155
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 6ddckc
|
|
156
|
+
problems.append(
|
|
157
|
+
f"{current}: referenced by {', '.join(files)} but is "
|
|
158
|
+
f"{data.get('status')}, not approved -- approve it through "
|
|
159
|
+
f"a corpus pull request, or reqctl untag each citation of "
|
|
160
|
+
f"{current}"
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
# @req> REQ-77594104@x3D--4236Dno lgfhiu
|
|
164
|
+
if corpus.citable(item):
|
|
165
|
+
for path, pinned, entry in cited:
|
|
166
|
+
# @req+ REQ-74122607@f-RdDZCNkgqC pbsx4y
|
|
167
|
+
address = f"{current}.{entry}" if entry else current
|
|
168
|
+
if entry and entry not in corpus.cited_entries(item):
|
|
169
|
+
# @req> REQ-30898926@Vu28eWaG_1mE fv4ej6
|
|
170
|
+
problems.append(
|
|
171
|
+
f"{address}: referenced by {path} but no such entry -- "
|
|
172
|
+
f"reqctl untag the citation, or add the entry with "
|
|
173
|
+
f"reqctl revise {current} --new-entry {entry}")
|
|
174
|
+
continue
|
|
175
|
+
held = corpus.cited_stamp(item, entry)
|
|
176
|
+
# @req- pbsx4y
|
|
177
|
+
# @req> REQ-75161909@GUQmRsLIBj6Z 7df3my
|
|
178
|
+
if not pinned:
|
|
179
|
+
problems.append(unstamped(address, path))
|
|
180
|
+
elif pinned != held:
|
|
181
|
+
# @req> REQ-24608716@rrTjNN2Ay0D0 ofe7e3
|
|
182
|
+
stale.append({"uid": address, "path": path,
|
|
183
|
+
"pinned": pinned, "held": held,
|
|
184
|
+
**past.cited(current, data, entry, pinned)})
|
|
185
|
+
# @req> REQ-73027720@2Z15jgYQYfsS 3aqkc5
|
|
186
|
+
if current.startswith(("REQ-", "GUARD-")) and not files:
|
|
187
|
+
unimplemented.append(current)
|
|
188
|
+
|
|
189
|
+
if not uid:
|
|
190
|
+
for tagged in sorted(tags):
|
|
191
|
+
if tagged not in known:
|
|
192
|
+
# @req> REQ-30898926@Vu28eWaG_1mE li6jfv
|
|
193
|
+
problems.append(
|
|
194
|
+
f"{tagged}: referenced by "
|
|
195
|
+
f"{', '.join(dict.fromkeys(p for p, _, _ in tags[tagged]))}"
|
|
196
|
+
" but no such requirement -- reqctl untag each citation "
|
|
197
|
+
f"of {tagged}, or mint it with reqctl new KIND --uid "
|
|
198
|
+
f"{tagged}"
|
|
199
|
+
)
|
|
200
|
+
# @req> REQ-67655319@ezp6TxUzJ72E eznv3q
|
|
201
|
+
# @req> REQ-13384695@P9NXsZKZp683 qtu6ly
|
|
202
|
+
# @req> REQ-32191310@aZkvQ6hvIwPv 65dur7
|
|
203
|
+
problems += listing(tree, citations, uid)
|
|
204
|
+
|
|
205
|
+
return {"rows": rows, "problems": problems, "stale": stale,
|
|
206
|
+
"deprecated": deprecated, "unimplemented": sorted(unimplemented),
|
|
207
|
+
"citations": [c for c in shown if not uid or c["uid"] == uid]}
|