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