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/cli.py
ADDED
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import contextlib
|
|
3
|
+
import dataclasses
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import webbrowser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import baseline as _baseline
|
|
14
|
+
from . import cite
|
|
15
|
+
from . import corpus
|
|
16
|
+
from . import fields as _fields
|
|
17
|
+
from . import graph
|
|
18
|
+
from . import history
|
|
19
|
+
from . import portal as _portal
|
|
20
|
+
from . import resolve as _resolve
|
|
21
|
+
from . import write as _write
|
|
22
|
+
from . import validate as _validate
|
|
23
|
+
from .corpus import ReqctlError
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_INVALID = 1
|
|
27
|
+
|
|
28
|
+
BETWEEN_ITEMS = "\n\n" + "-" * 60 + "\n\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _emit(args, data, text):
|
|
32
|
+
print(json.dumps(data, indent=2) if args.json else text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _refuse_invalid(root, said):
|
|
36
|
+
problems = _validate.run(root)
|
|
37
|
+
if problems:
|
|
38
|
+
# @req> REQ-30898926@Vu28eWaG_1mE hjpd4a
|
|
39
|
+
raise ReqctlError("the corpus does not validate; fix these, "
|
|
40
|
+
f"then {said}:\n" + "\n".join(problems))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_new(args):
|
|
44
|
+
store, _ = corpus.load()
|
|
45
|
+
item = _write.create(store, args.kind, vars(args))
|
|
46
|
+
_emit(args, {"uid": item.uid, "path": str(item.path)}, item.uid)
|
|
47
|
+
return EXIT_OK
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_lint(args):
|
|
51
|
+
store, _ = corpus.load()
|
|
52
|
+
# @req> REQ-85308584@4lLbQIsexIid sri72v
|
|
53
|
+
uid, _, _, problems = _write.prepare(store, args.kind, vars(args),
|
|
54
|
+
placeholder="draft")
|
|
55
|
+
faults = [problem.removeprefix(f"{uid}: ") for problem in problems]
|
|
56
|
+
if args.json:
|
|
57
|
+
_emit(args, {"faults": faults}, "")
|
|
58
|
+
elif faults:
|
|
59
|
+
print("\n".join(faults), file=sys.stderr)
|
|
60
|
+
else:
|
|
61
|
+
print("no faults")
|
|
62
|
+
return EXIT_INVALID if faults else EXIT_OK
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _describe(tree, item):
|
|
66
|
+
data = corpus.raw(item)
|
|
67
|
+
uid = str(item.uid)
|
|
68
|
+
_refuse_broken(uid, data)
|
|
69
|
+
out = {
|
|
70
|
+
"uid": uid,
|
|
71
|
+
"kind": corpus.kind_of(uid, data),
|
|
72
|
+
"status": data.get("status"),
|
|
73
|
+
"stamp": corpus.stamp(item),
|
|
74
|
+
"text": str(data.get("text") or "").strip(),
|
|
75
|
+
}
|
|
76
|
+
if corpus.kind_of(uid, data) in ("requirement", "guard"):
|
|
77
|
+
out.update(
|
|
78
|
+
references=corpus.references(data),
|
|
79
|
+
concepts=corpus.concept_references(data),
|
|
80
|
+
acceptance_criteria=data.get("acceptance_criteria") or [],
|
|
81
|
+
)
|
|
82
|
+
if corpus.kind_of(uid, data) == "requirement":
|
|
83
|
+
out.update(
|
|
84
|
+
type=data.get("type"),
|
|
85
|
+
verification=data.get("verification"),
|
|
86
|
+
priority=data.get("priority"),
|
|
87
|
+
)
|
|
88
|
+
elif corpus.kind_of(uid, data) == "term":
|
|
89
|
+
out.pop("text")
|
|
90
|
+
fields = corpus.term_fields(data)
|
|
91
|
+
out.update(
|
|
92
|
+
term=corpus.term_word(data),
|
|
93
|
+
aliases=fields.get("aliases") or [],
|
|
94
|
+
definition=str(fields.get("definition") or "").strip(),
|
|
95
|
+
)
|
|
96
|
+
# @req> REQ-22686458@dmNVoBq6qCcv q5tknl
|
|
97
|
+
out["unclaimed"] = corpus.unclaimed(data)
|
|
98
|
+
elif corpus.kind_of(uid, data) == "data":
|
|
99
|
+
out.update(name=data.get("name"), entries=corpus.entries(data) or {})
|
|
100
|
+
# @req> REQ-32949873@Zm9yVRdGWKOQ 4ffzw2
|
|
101
|
+
out.update(corpus.held_blocks(tree.root, uid, data))
|
|
102
|
+
else:
|
|
103
|
+
out.update(
|
|
104
|
+
name=data.get("name"),
|
|
105
|
+
value=_dict_value(data),
|
|
106
|
+
unit=data.get("unit"),
|
|
107
|
+
value_type=data.get("value_type"),
|
|
108
|
+
)
|
|
109
|
+
described = {key: fields for key, fields
|
|
110
|
+
in (corpus.entries(data) or {}).items() if fields}
|
|
111
|
+
if described:
|
|
112
|
+
out["entries"] = described
|
|
113
|
+
if data.get("default") is not None:
|
|
114
|
+
out["default"] = data.get("default")
|
|
115
|
+
if data.get("rationale"):
|
|
116
|
+
out["rationale"] = str(data["rationale"]).strip()
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _refuse_broken(uid, data):
|
|
121
|
+
if (corpus.kind_of(uid, data) not in ("requirement", "guard")
|
|
122
|
+
and corpus.entries(data) is None):
|
|
123
|
+
# @req> REQ-30898926@Vu28eWaG_1mE zlnal5
|
|
124
|
+
raise ReqctlError(
|
|
125
|
+
f"{uid}: has no entries -- `reqctl validate` names the fault; "
|
|
126
|
+
f"edit {uid}'s file by hand so entries maps each key to its fields"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _dict_value(data):
|
|
131
|
+
return sorted(corpus.entries(data) or {})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _criterion(mapping):
|
|
135
|
+
if not isinstance(mapping, dict):
|
|
136
|
+
return str(mapping)
|
|
137
|
+
return (f"given {mapping.get('given')} / when {mapping.get('when')} "
|
|
138
|
+
f"/ then {mapping.get('then')}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _shown(data):
|
|
142
|
+
held = corpus.entries(data) or {}
|
|
143
|
+
suffix = f" {data.get('unit')}" if data.get("unit") else ""
|
|
144
|
+
default = data.get("default")
|
|
145
|
+
return ", ".join(f"{key}{suffix}" + (" (default)" if key == default else "")
|
|
146
|
+
for key in held)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _value(value, unit):
|
|
150
|
+
# @req> REQ-49042671@vhxkl2dsMRfs t3z2ju
|
|
151
|
+
if isinstance(value, dict):
|
|
152
|
+
return ", ".join(f"{field}: {held}" for field, held in value.items())
|
|
153
|
+
suffix = f" {unit}" if unit else ""
|
|
154
|
+
return ", ".join(f"{member}{suffix}" for member in value)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _named_fields(fields, rest, blocks):
|
|
158
|
+
steps = rest.split(".")
|
|
159
|
+
found = corpus.entries(fields) or {}
|
|
160
|
+
if steps[0] in blocks and len(steps) > 1:
|
|
161
|
+
found, steps = blocks[steps[0]], steps[1:]
|
|
162
|
+
for step in steps:
|
|
163
|
+
if not isinstance(found, dict) or step not in found:
|
|
164
|
+
return None
|
|
165
|
+
found = found[step]
|
|
166
|
+
if len(steps) == 1 and isinstance(found, dict):
|
|
167
|
+
return dict(found)
|
|
168
|
+
return {steps[-1]: found}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parameter_value(fields, named, held):
|
|
172
|
+
if named is not None:
|
|
173
|
+
return named
|
|
174
|
+
if held is not None:
|
|
175
|
+
return sorted(held)
|
|
176
|
+
return _dict_value(fields)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _entry_fields(fields):
|
|
180
|
+
# @req> REQ-73719888@SQkvdemRTgC1 3wgrmj
|
|
181
|
+
return ", ".join(f"{name}: {corpus.plain(value)}"
|
|
182
|
+
for name, value in (fields or {}).items())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _nests(fields):
|
|
186
|
+
return any(isinstance(value, dict) for value in (fields or {}).values())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _entry_lines(key, fields, depth=0, anchor=None):
|
|
190
|
+
# @req> REQ-22770949@2z1l08YISr1R cglw6c
|
|
191
|
+
label = f'<a id="{anchor}"></a>{key}' if anchor else key
|
|
192
|
+
if depth == 0 and not _nests(fields):
|
|
193
|
+
shown = _entry_fields(fields)
|
|
194
|
+
return [f"- {label}" + (f" — {shown}" if shown else "")]
|
|
195
|
+
# @req+ REQ-18993149@6FrsyHRkHj2O s5nw3l
|
|
196
|
+
held = [f"{' ' * depth}- {label}"]
|
|
197
|
+
for name, value in (fields or {}).items():
|
|
198
|
+
if isinstance(value, dict):
|
|
199
|
+
held += _entry_lines(name, value, depth + 1)
|
|
200
|
+
else:
|
|
201
|
+
# @req> REQ-73719888@SQkvdemRTgC1 g5txax
|
|
202
|
+
held.append(f"{' ' * (depth + 1)}- {name}: {corpus.plain(value)}")
|
|
203
|
+
return held
|
|
204
|
+
# @req- s5nw3l
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _render(fields, order=None):
|
|
208
|
+
keys = order or list(fields)
|
|
209
|
+
width = max((len(k) for k in keys if fields.get(k) is not None), default=0)
|
|
210
|
+
lines = []
|
|
211
|
+
# @req> REQ-73719888@SQkvdemRTgC1 li6wlm
|
|
212
|
+
for key in keys:
|
|
213
|
+
value = fields.get(key)
|
|
214
|
+
if value in (None, [], "", {}):
|
|
215
|
+
continue
|
|
216
|
+
if isinstance(value, dict):
|
|
217
|
+
value = "; ".join(
|
|
218
|
+
f"{name} -- {_entry_fields(held) if isinstance(held, dict) else corpus.plain(held)}"
|
|
219
|
+
if held else str(name)
|
|
220
|
+
for name, held in value.items()
|
|
221
|
+
)
|
|
222
|
+
if isinstance(value, list):
|
|
223
|
+
value = ", ".join(_criterion(v) if isinstance(v, dict) else corpus.plain(v)
|
|
224
|
+
for v in value)
|
|
225
|
+
lines.append(f"{key.replace('_', ' '):<{width}} {corpus.plain(value)}")
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_revise(args):
|
|
230
|
+
tree, _ = corpus.load()
|
|
231
|
+
# @req+ REQ-94401451@EZFY5LjHcj9g pc3x5s
|
|
232
|
+
if len(set(args.uid)) > 1:
|
|
233
|
+
changes = _write.revise_together(tree, args.uid, vars(args))
|
|
234
|
+
_emit(args, {"changed": changes},
|
|
235
|
+
"\n".join(f"{uid}: " + (", ".join(sorted(changed)) or "already held")
|
|
236
|
+
for uid, changed in changes.items())
|
|
237
|
+
+ "\n proposed as approved -- approved when this merges to main")
|
|
238
|
+
return EXIT_OK
|
|
239
|
+
args.uid = args.uid[0]
|
|
240
|
+
# @req- pc3x5s
|
|
241
|
+
changed = _write.revise(tree, args.uid, vars(args))
|
|
242
|
+
if not changed:
|
|
243
|
+
_emit(args, {"uid": args.uid, "changed": {}, "already_held": True},
|
|
244
|
+
f"{args.uid}: already holds everything passed; nothing written")
|
|
245
|
+
return EXIT_OK
|
|
246
|
+
note = ""
|
|
247
|
+
if changed.get("status") == "approved":
|
|
248
|
+
note = "\n proposed as approved -- it is approved when this merges to main"
|
|
249
|
+
spread = changed.get("reworded")
|
|
250
|
+
if spread:
|
|
251
|
+
note += (f"\n reworded {spread['links']} link(s) across "
|
|
252
|
+
f"{len(spread['items'])} item(s)")
|
|
253
|
+
for held in spread["articles"]:
|
|
254
|
+
note += f"\n article: {held}"
|
|
255
|
+
if spread["left"]:
|
|
256
|
+
note += ("\n still showing another word, yours to reword or leave: "
|
|
257
|
+
+ ", ".join(spread["left"]))
|
|
258
|
+
_emit(
|
|
259
|
+
args,
|
|
260
|
+
{"uid": args.uid, "changed": changed},
|
|
261
|
+
f"{args.uid}: " + ", ".join(sorted(changed)) + note,
|
|
262
|
+
)
|
|
263
|
+
return EXIT_OK
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_relate(args):
|
|
267
|
+
tree, _ = corpus.load()
|
|
268
|
+
repinned = _write.relate(tree, args.source, args.relation, args.target)
|
|
269
|
+
note = (
|
|
270
|
+
": re-pinned to the target's current state"
|
|
271
|
+
if repinned
|
|
272
|
+
else "\n unassessed -- reread both, then run the same relate to pin it"
|
|
273
|
+
)
|
|
274
|
+
_emit(
|
|
275
|
+
args,
|
|
276
|
+
{"source": args.source, "relation": args.relation, "target": args.target,
|
|
277
|
+
"repinned": repinned},
|
|
278
|
+
f"{args.source} {args.relation} {args.target}" + note,
|
|
279
|
+
)
|
|
280
|
+
return EXIT_OK
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_repin_links(args):
|
|
284
|
+
tree, _ = corpus.load()
|
|
285
|
+
repinned = _write.repin_links(tree, args.uids, args.all)
|
|
286
|
+
_emit(args, repinned,
|
|
287
|
+
"\n".join(f"{uid}: re-pinned {', '.join(addresses)}"
|
|
288
|
+
for uid, addresses in repinned.items())
|
|
289
|
+
or "nothing to re-pin; nothing was written")
|
|
290
|
+
return EXIT_OK
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_unrelate(args):
|
|
294
|
+
tree, _ = corpus.load()
|
|
295
|
+
removed = _write.unrelate(tree, args.source, args.target)
|
|
296
|
+
_emit(args, {"source": args.source, "target": args.target,
|
|
297
|
+
"removed": removed},
|
|
298
|
+
f"{args.source} no longer links to {args.target}"
|
|
299
|
+
+ ("" if removed else " (it already did not)"))
|
|
300
|
+
return EXIT_OK
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cmd_delete(args):
|
|
304
|
+
tree, root = corpus.load()
|
|
305
|
+
# @req> REQ-85755152@bVyS0LwfT-4B xuxjui
|
|
306
|
+
corpus.find(tree, args.uid)
|
|
307
|
+
# @req> REQ-43148967@9kz9xDxvdKH5 nghnwd
|
|
308
|
+
_refuse_invalid(root, "remove")
|
|
309
|
+
where = _write.delete(tree, args.uid)
|
|
310
|
+
_emit(args, {"uid": args.uid, "path": str(where)},
|
|
311
|
+
f"{args.uid} removed. The baseline the default branch carries did "
|
|
312
|
+
"not state it, so nothing it said is taken back.")
|
|
313
|
+
return EXIT_OK
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cmd_rename(args):
|
|
317
|
+
tree, root = corpus.load()
|
|
318
|
+
# @req+ REQ-70208796@rx8OkxlDOpk4 cgdwfu
|
|
319
|
+
item = corpus.find(tree, args.uid)
|
|
320
|
+
olds = {item.uid, corpus.name_of(item.uid, item.data)} - {None}
|
|
321
|
+
cited = [citation for citation in cite.every(root)
|
|
322
|
+
if citation["uid"] in olds]
|
|
323
|
+
moved = _write.rename(tree, args.uid, args.name)
|
|
324
|
+
for citation in cited:
|
|
325
|
+
corpus.atomic_write(*cite.renamed(root, citation, args.name))
|
|
326
|
+
# @req- cgdwfu
|
|
327
|
+
_emit(args, {"uid": args.uid, "name": args.name, "moved": moved},
|
|
328
|
+
f"{args.uid} answers to {args.name}. "
|
|
329
|
+
f"{len(moved)} item(s) now address it by that name.")
|
|
330
|
+
return EXIT_OK
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def cmd_refile(args):
|
|
334
|
+
tree, _ = corpus.load()
|
|
335
|
+
if args.into:
|
|
336
|
+
address, moved = _write.refile_into(tree, args.uid, *args.into)
|
|
337
|
+
_emit(args, {"uid": args.uid, "address": address, "moved": moved},
|
|
338
|
+
f"{args.uid} is filed as {address} and deprecated. "
|
|
339
|
+
f"{len(moved)} item(s) now reference {address}.")
|
|
340
|
+
return EXIT_OK
|
|
341
|
+
repinned = _write.refile(tree, args.uid)
|
|
342
|
+
_emit(args, {"uid": args.uid, "kind": "data", "repinned": repinned},
|
|
343
|
+
f"{args.uid} is filed as a data item. "
|
|
344
|
+
f"{len(repinned)} item(s) had a pin to it refreshed.")
|
|
345
|
+
return EXIT_OK
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _resolved(text, store):
|
|
349
|
+
# @req+ REQ-70783238@_ngMgAcYCcxF lxx4c3
|
|
350
|
+
# @req> REQ-68562495@YhDgLuskl6yq i2bing
|
|
351
|
+
def parameter(match):
|
|
352
|
+
try:
|
|
353
|
+
item = corpus.find(store, match.group(1))
|
|
354
|
+
except ReqctlError:
|
|
355
|
+
return match.group(0)
|
|
356
|
+
fields = item.data
|
|
357
|
+
held = corpus.entries(fields)
|
|
358
|
+
if held is None:
|
|
359
|
+
return match.group(0)
|
|
360
|
+
unit = fields.get("unit")
|
|
361
|
+
rest = match.group(2)[1:] if match.group(2) else None
|
|
362
|
+
if rest is None:
|
|
363
|
+
shown = _shown(fields)
|
|
364
|
+
elif rest == "default":
|
|
365
|
+
member = fields.get("default")
|
|
366
|
+
if member is None:
|
|
367
|
+
return match.group(0)
|
|
368
|
+
shown = f"{member} {unit}" if unit else str(member)
|
|
369
|
+
else:
|
|
370
|
+
entry, _, field = rest.partition(".")
|
|
371
|
+
# @req+ REQ-11930677@dH7Pu7k4K4YI ne5nva
|
|
372
|
+
block = corpus.held_blocks(store.root, item.uid, fields).get(entry)
|
|
373
|
+
if block is not None:
|
|
374
|
+
held, unit = block, None
|
|
375
|
+
entry, _, field = field.partition(".")
|
|
376
|
+
# @req- ne5nva
|
|
377
|
+
if entry not in held:
|
|
378
|
+
return match.group(0)
|
|
379
|
+
if field:
|
|
380
|
+
value = (held[entry] if isinstance(held[entry], dict) else {}).get(field)
|
|
381
|
+
if isinstance(value, list):
|
|
382
|
+
value = _value(value, None) or None
|
|
383
|
+
if value is None:
|
|
384
|
+
return match.group(0)
|
|
385
|
+
shown = field if isinstance(value, dict) else str(value)
|
|
386
|
+
else:
|
|
387
|
+
# @req+ REQ-62905646@_RkSNpGGoJXv cs3jo4
|
|
388
|
+
stated = (block or corpus.cited_entries(item)).get(entry)
|
|
389
|
+
if (isinstance(stated, dict) and "value_type" in stated
|
|
390
|
+
and "quantity" in stated and _write.typed(store)):
|
|
391
|
+
shown = _value([stated["quantity"]], stated.get("unit"))
|
|
392
|
+
else:
|
|
393
|
+
shown = f"{entry} {unit}" if unit else entry
|
|
394
|
+
# @req- cs3jo4
|
|
395
|
+
return f"[**{shown}**](#{item.uid})"
|
|
396
|
+
# @req- lxx4c3
|
|
397
|
+
|
|
398
|
+
# @req> REQ-73719888@SQkvdemRTgC1 5ubj6l
|
|
399
|
+
def term(match):
|
|
400
|
+
uid, rest = corpus.split_address(match.group(2))
|
|
401
|
+
# @req+ REQ-13364391@mP1skaGV3Fbm o72rua
|
|
402
|
+
try:
|
|
403
|
+
found = corpus.find(store, uid)
|
|
404
|
+
except ReqctlError:
|
|
405
|
+
return match.group(0)
|
|
406
|
+
# @req- o72rua
|
|
407
|
+
if rest is None:
|
|
408
|
+
return f"[{match.group(1)}](#{found.uid})"
|
|
409
|
+
# @req> REQ-13364391@mP1skaGV3Fbm fgekao
|
|
410
|
+
if corpus.concept_at(store.root, found.uid, found.data, rest) is None:
|
|
411
|
+
return match.group(0)
|
|
412
|
+
# @req> REQ-22770949@2z1l08YISr1R 54eioc
|
|
413
|
+
return f"[{match.group(1)}](#{found.uid}.{rest})"
|
|
414
|
+
|
|
415
|
+
text = corpus.PARAM_REF.sub(parameter, " ".join(str(text or "").split()))
|
|
416
|
+
return corpus.CONCEPT_LINK.sub(term, text)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def cmd_export(args):
|
|
420
|
+
tree, root = corpus.load()
|
|
421
|
+
wanted = None if args.all else "approved"
|
|
422
|
+
past = history.History(root)
|
|
423
|
+
if args.json:
|
|
424
|
+
held = {str(item.uid): dict(corpus.raw(item))
|
|
425
|
+
for item in corpus.items(tree)}
|
|
426
|
+
# @req> REQ-82027237@R-XinrGfTNzL ntmmgk
|
|
427
|
+
print(json.dumps({uid: {**data, "last_updated": past.updated(uid, data)}
|
|
428
|
+
for uid, data in sorted(held.items())
|
|
429
|
+
if not wanted or data.get("status") == wanted},
|
|
430
|
+
indent=2, default=str))
|
|
431
|
+
return EXIT_OK
|
|
432
|
+
|
|
433
|
+
lines = ["# requirements", ""]
|
|
434
|
+
if wanted:
|
|
435
|
+
lines += ["Approved requirements only. Use these when judging whether a new",
|
|
436
|
+
"idea is already covered, contradicts something, or belongs beside", "one of them.", ""]
|
|
437
|
+
used_by = {}
|
|
438
|
+
for item in corpus.items(tree):
|
|
439
|
+
for address in corpus.concept_references(corpus.raw(item)):
|
|
440
|
+
target, _ = corpus.split_address(address)
|
|
441
|
+
try:
|
|
442
|
+
target = corpus.find(tree, target).uid
|
|
443
|
+
except ReqctlError:
|
|
444
|
+
pass
|
|
445
|
+
used_by.setdefault(target, []).append(str(item.uid))
|
|
446
|
+
|
|
447
|
+
count = 0
|
|
448
|
+
for item in corpus.items(tree):
|
|
449
|
+
stored = corpus.raw(item)
|
|
450
|
+
if wanted and stored.get("status") != wanted:
|
|
451
|
+
continue
|
|
452
|
+
count += 1
|
|
453
|
+
uid = str(item.uid)
|
|
454
|
+
_refuse_broken(uid, stored)
|
|
455
|
+
# @req+ REQ-59874866@fH6bFHN-Lwa8 6drjr2
|
|
456
|
+
try:
|
|
457
|
+
declared = set(_validate.prose_paths(root, uid, stored))
|
|
458
|
+
except ReqctlError as unreadable:
|
|
459
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 5djpyt
|
|
460
|
+
raise ReqctlError(f"{uid}: {unreadable}") from unreadable
|
|
461
|
+
data = corpus.prosed(stored, declared,
|
|
462
|
+
lambda text: _resolved(text, tree))
|
|
463
|
+
# @req- 6drjr2
|
|
464
|
+
lines.append(f'<a id="{uid}"></a>')
|
|
465
|
+
lines.append(f"## {uid}")
|
|
466
|
+
# @req> REQ-59306961@EGz7Ll3e9FKA dtr4za
|
|
467
|
+
# @req> REQ-82027237@R-XinrGfTNzL 3adxsp
|
|
468
|
+
lines.append(f"- last updated: {past.updated(uid, stored) or 'unknown'}")
|
|
469
|
+
# @req> REQ-22770949@2z1l08YISr1R okkskg
|
|
470
|
+
marks = "".join(f'<a id="{uid}.{key}"></a>'
|
|
471
|
+
for key in corpus.entries(data) or {}
|
|
472
|
+
if corpus.DATA_KEY.match(str(key)))
|
|
473
|
+
if corpus.kind_of(uid, data) == "term":
|
|
474
|
+
fields = corpus.term_fields(data)
|
|
475
|
+
# @req> REQ-22770949@2z1l08YISr1R nnfbvp
|
|
476
|
+
lines.append(f"{marks}**{corpus.term_word(data)}** — "
|
|
477
|
+
f"{' '.join(str(fields.get('definition') or '').split())}")
|
|
478
|
+
aliases = fields.get("aliases") or []
|
|
479
|
+
if aliases:
|
|
480
|
+
lines.append(f"- also: {', '.join(str(a) for a in aliases)}")
|
|
481
|
+
# @req+ REQ-18993149@6FrsyHRkHj2O yxze4j
|
|
482
|
+
unclaimed = corpus.unclaimed(data)
|
|
483
|
+
if unclaimed:
|
|
484
|
+
lines.append("- unclaimed")
|
|
485
|
+
lines += [f" - {phrase}: {reason}"
|
|
486
|
+
for phrase, reason in unclaimed.items()]
|
|
487
|
+
# @req- yxze4j
|
|
488
|
+
lines.append(f"- status: {data.get('status')}")
|
|
489
|
+
users = sorted(used_by.get(uid, ()))
|
|
490
|
+
if users:
|
|
491
|
+
lines.append("- used by: "
|
|
492
|
+
+ ", ".join(f"[{u}](#{u})" for u in users))
|
|
493
|
+
lines.append("")
|
|
494
|
+
continue
|
|
495
|
+
if corpus.kind_of(uid, data) in ("requirement", "guard"):
|
|
496
|
+
lines.append(str(data.get("text") or ""))
|
|
497
|
+
lines.append("")
|
|
498
|
+
lines.append(f"- status: {data.get('status')}")
|
|
499
|
+
if corpus.kind_of(uid, data) == "requirement":
|
|
500
|
+
lines.append(
|
|
501
|
+
f"- type: {data.get('type')} · verification: "
|
|
502
|
+
f"{data.get('verification')} · priority: {data.get('priority')}")
|
|
503
|
+
for target, relation in sorted(corpus.mapping(data, "relations").items()):
|
|
504
|
+
lines.append(f"- {relation} {target}")
|
|
505
|
+
for criterion in data.get("acceptance_criteria") or []:
|
|
506
|
+
lines.append(f"- {_criterion(criterion)}")
|
|
507
|
+
elif corpus.kind_of(uid, data) == "data":
|
|
508
|
+
lines.append(f"`{data.get('name')}`")
|
|
509
|
+
if data.get("text"):
|
|
510
|
+
lines.append(str(data.get("text")))
|
|
511
|
+
for key, fields in (corpus.entries(data) or {}).items():
|
|
512
|
+
# @req> REQ-22770949@2z1l08YISr1R qc2ek5
|
|
513
|
+
lines += _entry_lines(key, fields, anchor=f"{uid}.{key}")
|
|
514
|
+
# @req> REQ-32949873@Zm9yVRdGWKOQ iwaspp
|
|
515
|
+
for block, held in corpus.held_blocks(root, uid, data).items():
|
|
516
|
+
lines.append(f"- {block}")
|
|
517
|
+
for member, fields in held.items():
|
|
518
|
+
lines += (_entry_lines(member, fields, 1, f"{uid}.{block}.{member}")
|
|
519
|
+
if isinstance(fields, dict)
|
|
520
|
+
else [f" - {member}: {corpus.plain(fields)}"])
|
|
521
|
+
lines.append(f"- status: {data.get('status')}")
|
|
522
|
+
else:
|
|
523
|
+
# @req> REQ-22770949@2z1l08YISr1R adyi2v
|
|
524
|
+
lines.append(f"{marks}{str(data.get('text') or '')} "
|
|
525
|
+
f"**{_shown(data)}** "
|
|
526
|
+
f"(`{data.get('name')}`)")
|
|
527
|
+
for key, fields in (corpus.entries(data) or {}).items():
|
|
528
|
+
if fields:
|
|
529
|
+
lines += _entry_lines(key, fields)
|
|
530
|
+
lines.append(f"- status: {data.get('status')}")
|
|
531
|
+
if data.get("rationale"):
|
|
532
|
+
lines.append(f"- rationale: {data['rationale']}")
|
|
533
|
+
lines.append("")
|
|
534
|
+
|
|
535
|
+
if not count:
|
|
536
|
+
lines.append("_Nothing approved yet._")
|
|
537
|
+
print("\n".join(lines))
|
|
538
|
+
return EXIT_OK
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _behind(row, data):
|
|
542
|
+
if row is None:
|
|
543
|
+
return ""
|
|
544
|
+
# @req> REQ-24608716@rrTjNN2Ay0D0 4lmsbv
|
|
545
|
+
if row["pinned"]:
|
|
546
|
+
return (f" SUSPECT -- pinned @{row['pinned']}, now @{row['held']}"
|
|
547
|
+
+ history.lines(row))
|
|
548
|
+
return " SUSPECT -- no stamp; cite it again with reqctl tag"
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# @req> REQ-24608716@rrTjNN2Ay0D0 o2outx
|
|
552
|
+
def _pins(store, past, binder, addresses):
|
|
553
|
+
# @req> REQ-81493991@71cbxqAtqZTk dqf5kb
|
|
554
|
+
if binder.data.get("status") in _validate.RETIRED:
|
|
555
|
+
return {"suspect": False, "pins": []}
|
|
556
|
+
pins = corpus.mapping(binder.data, "assessed")
|
|
557
|
+
held = [{"address": address, "pinned": pins.get(address),
|
|
558
|
+
**past.pinned(bound.uid, bound.data, rest, pins.get(address))}
|
|
559
|
+
for address in addresses if corpus.is_suspect(store, binder, address)
|
|
560
|
+
for target, rest in [corpus.split_address(address)]
|
|
561
|
+
for bound in [corpus.find(store, target)]]
|
|
562
|
+
return {"suspect": bool(held), "pins": held}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# @req> REQ-24608716@rrTjNN2Ay0D0 3c5yr2
|
|
566
|
+
def _shown_pins(row):
|
|
567
|
+
return "".join(history.lines(pin) for pin in row.get("pins", ()))
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _context(store, item, dependents, tags, past):
|
|
571
|
+
uid = item.uid
|
|
572
|
+
kinds = corpus.mapping(item.data, "relations")
|
|
573
|
+
pins = corpus.mapping(item.data, "assessed")
|
|
574
|
+
|
|
575
|
+
outgoing = []
|
|
576
|
+
for target in sorted(kinds):
|
|
577
|
+
entry = {
|
|
578
|
+
"target": target,
|
|
579
|
+
"relation": kinds.get(target),
|
|
580
|
+
"assessed_against": pins.get(target),
|
|
581
|
+
}
|
|
582
|
+
try:
|
|
583
|
+
corpus.find(store, target)
|
|
584
|
+
except ReqctlError:
|
|
585
|
+
entry.update(suspect=True, missing=True)
|
|
586
|
+
else:
|
|
587
|
+
entry.update(missing=False, **_pins(store, past, item, [target]))
|
|
588
|
+
outgoing.append(entry)
|
|
589
|
+
|
|
590
|
+
incoming = [
|
|
591
|
+
{
|
|
592
|
+
"source": other.uid,
|
|
593
|
+
"relation": corpus.mapping(other.data, "relations").get(uid)
|
|
594
|
+
or "references",
|
|
595
|
+
**_pins(store, past, other, [uid]),
|
|
596
|
+
}
|
|
597
|
+
for other in dependents.get(uid, ()) if other.uid != uid
|
|
598
|
+
]
|
|
599
|
+
|
|
600
|
+
lookup = corpus.data_lookup(store)
|
|
601
|
+
parameters = []
|
|
602
|
+
for address in corpus.references(item.data):
|
|
603
|
+
target, rest = corpus.split_address(address)
|
|
604
|
+
entry = {"uid": address, "assessed_against": pins.get(address)}
|
|
605
|
+
try:
|
|
606
|
+
fields = corpus.find(store, target).data
|
|
607
|
+
except ReqctlError:
|
|
608
|
+
fields = None
|
|
609
|
+
# @req+ REQ-49042671@vhxkl2dsMRfs pgeszq
|
|
610
|
+
held = corpus.flagged(fields, rest) if fields is not None else None
|
|
611
|
+
addresses_item = rest in (None, "default", corpus.MEMBERS) or held is not None
|
|
612
|
+
# @req> REQ-11930677@dH7Pu7k4K4YI h6snkl
|
|
613
|
+
named = (None if fields is None or addresses_item
|
|
614
|
+
else _named_fields(fields, rest, corpus.held_blocks(
|
|
615
|
+
store.root, target, fields)))
|
|
616
|
+
if fields is None or (not addresses_item and named is None):
|
|
617
|
+
entry.update(name=None, value=None, unit=None, status=None,
|
|
618
|
+
missing=True, suspect=True)
|
|
619
|
+
else:
|
|
620
|
+
entry.update(name=fields.get("name"),
|
|
621
|
+
value=_parameter_value(fields, named, held),
|
|
622
|
+
unit=fields.get("unit"), status=fields.get("status"),
|
|
623
|
+
missing=False, **_pins(store, past, item, sorted(
|
|
624
|
+
corpus.expand_address(lookup, address))))
|
|
625
|
+
# @req- pgeszq
|
|
626
|
+
parameters.append(entry)
|
|
627
|
+
|
|
628
|
+
concepts = []
|
|
629
|
+
for address in corpus.concept_references(item.data):
|
|
630
|
+
entry = {"uid": address, "assessed_against": pins.get(address)}
|
|
631
|
+
target, rest = corpus.split_address(address)
|
|
632
|
+
try:
|
|
633
|
+
fields = corpus.find(store, target).data
|
|
634
|
+
except ReqctlError:
|
|
635
|
+
fields = None
|
|
636
|
+
held = corpus.concept_at(store.root, target, fields, rest)
|
|
637
|
+
if held is None:
|
|
638
|
+
entry.update(word=None, definition=None, status=None, missing=True,
|
|
639
|
+
suspect=True)
|
|
640
|
+
else:
|
|
641
|
+
entry.update(word=held[0], definition=held[1],
|
|
642
|
+
status=fields.get("status"), missing=False,
|
|
643
|
+
**_pins(store, past, item, [address]))
|
|
644
|
+
concepts.append(entry)
|
|
645
|
+
|
|
646
|
+
# @req> REQ-19896380@lvCPXiVYZ6ud 7iy6c4
|
|
647
|
+
cited = [(path, pinned) for path, pinned, _ in tags.get(uid, [])
|
|
648
|
+
if not corpus.is_test(path)]
|
|
649
|
+
references = list(dict.fromkeys(path for path, _ in cited))
|
|
650
|
+
described = _describe(store, item)
|
|
651
|
+
# @req> REQ-24608716@rrTjNN2Ay0D0 2texd5
|
|
652
|
+
behind = {
|
|
653
|
+
path: {"path": path, "pinned": pinned or None,
|
|
654
|
+
"held": corpus.tag_stamp(described["stamp"]),
|
|
655
|
+
**past.cited(uid, item.data, None, pinned or None)}
|
|
656
|
+
for path, pinned in cited
|
|
657
|
+
if uid.startswith(("REQ-", "GUARD-"))
|
|
658
|
+
and pinned != corpus.tag_stamp(described["stamp"])
|
|
659
|
+
}
|
|
660
|
+
# @req> REQ-73390844@n9jY7LpISW2S s7yx36
|
|
661
|
+
# @req> REQ-82027237@R-XinrGfTNzL d3gygm
|
|
662
|
+
updated = past.updated(uid, item.data)
|
|
663
|
+
data = {
|
|
664
|
+
**described,
|
|
665
|
+
"last_updated": updated,
|
|
666
|
+
"outgoing": outgoing,
|
|
667
|
+
"incoming": incoming,
|
|
668
|
+
"parameters": parameters,
|
|
669
|
+
"concepts": concepts,
|
|
670
|
+
"implementation": references,
|
|
671
|
+
"stale": list(behind.values()),
|
|
672
|
+
"suspect_links": sum(
|
|
673
|
+
1 for r in outgoing + parameters + concepts if r["suspect"]
|
|
674
|
+
),
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
# @req> REQ-59306961@EGz7Ll3e9FKA yoxktl
|
|
678
|
+
# @req> REQ-82027237@R-XinrGfTNzL wmao62
|
|
679
|
+
blocks = [_render({**described, "last_updated": updated or "unknown"})]
|
|
680
|
+
linked = (
|
|
681
|
+
("parameters", "parameter", parameters,
|
|
682
|
+
lambda r: f"{r['name']} = {_value(r['value'], r['unit'])}"),
|
|
683
|
+
("concepts", "concept", concepts,
|
|
684
|
+
lambda r: f"{r['word']} -- {r['definition']}"),
|
|
685
|
+
)
|
|
686
|
+
for label, noun, rows, summary in linked:
|
|
687
|
+
if rows:
|
|
688
|
+
blocks.append(
|
|
689
|
+
f"\n{label}\n"
|
|
690
|
+
+ "\n".join(
|
|
691
|
+
f" {r['uid']} MISSING -- no such {noun}" if r["missing"]
|
|
692
|
+
else f" {r['uid']} {summary(r)}"
|
|
693
|
+
+ (f" {str(r['status']).upper()} -- not approved"
|
|
694
|
+
if r["status"] != "approved" else "")
|
|
695
|
+
+ (" SUSPECT -- reread, then revise "
|
|
696
|
+
f"{uid} --ack {r['uid']}" if r["suspect"] else "")
|
|
697
|
+
+ _shown_pins(r)
|
|
698
|
+
for r in rows
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
if outgoing:
|
|
702
|
+
blocks.append(
|
|
703
|
+
"\ndepends on\n"
|
|
704
|
+
+ "\n".join(
|
|
705
|
+
f" {r['relation']} {r['target']}"
|
|
706
|
+
+ (" MISSING -- no such item" if r["missing"]
|
|
707
|
+
else " SUSPECT -- reassess before implementing" if r["suspect"]
|
|
708
|
+
else "")
|
|
709
|
+
+ _shown_pins(r)
|
|
710
|
+
for r in outgoing
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
if incoming:
|
|
714
|
+
blocks.append(
|
|
715
|
+
"\ndepended on by\n"
|
|
716
|
+
+ "\n".join(
|
|
717
|
+
f" {r['source']} {r['relation']}"
|
|
718
|
+
f"{' SUSPECT' if r['suspect'] else ''}"
|
|
719
|
+
+ _shown_pins(r)
|
|
720
|
+
for r in incoming
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
blocks.append(
|
|
724
|
+
"\nimplementation\n"
|
|
725
|
+
+ ("\n".join(f" {p}{_behind(behind.get(p), data)}"
|
|
726
|
+
for p in references) or " none")
|
|
727
|
+
)
|
|
728
|
+
return data, "\n".join(blocks)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def cmd_context(args):
|
|
732
|
+
store, root = corpus.load()
|
|
733
|
+
# @req> REQ-35805881@37XtC4gyD98k dktwfq
|
|
734
|
+
tags = cite.cited(cite.readable(root))
|
|
735
|
+
# @req> REQ-40478019@_dwqsOpSOQXA fgrxuj
|
|
736
|
+
graph.whole(store, tags)
|
|
737
|
+
dependents = {}
|
|
738
|
+
for other in corpus.items(store):
|
|
739
|
+
for target in {corpus.split_address(a)[0]
|
|
740
|
+
for a in corpus.dependencies(other.data)}:
|
|
741
|
+
dependents.setdefault(target, []).append(other)
|
|
742
|
+
|
|
743
|
+
past = history.History(root)
|
|
744
|
+
rows = []
|
|
745
|
+
for uid in dict.fromkeys(args.uid):
|
|
746
|
+
try:
|
|
747
|
+
rows.append(_context(store, corpus.find(store, uid),
|
|
748
|
+
dependents, tags, past))
|
|
749
|
+
except ReqctlError as fault:
|
|
750
|
+
rows.append(({"uid": uid, "error": str(fault)},
|
|
751
|
+
_render({"uid": uid, "unreadable": str(fault)})))
|
|
752
|
+
|
|
753
|
+
held = [data for data, _ in rows]
|
|
754
|
+
if all("error" in data for data in held):
|
|
755
|
+
# @req> REQ-30898926@Vu28eWaG_1mE wo5x6j
|
|
756
|
+
raise ReqctlError("; ".join(data["error"] for data in held))
|
|
757
|
+
|
|
758
|
+
if args.json:
|
|
759
|
+
_emit(args, held, "")
|
|
760
|
+
return EXIT_OK
|
|
761
|
+
|
|
762
|
+
for data in held:
|
|
763
|
+
if "error" in data:
|
|
764
|
+
print(f"reqctl: {data['error']}", file=sys.stderr)
|
|
765
|
+
print(BETWEEN_ITEMS.join(text for _, text in rows))
|
|
766
|
+
return EXIT_OK
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _handle(uid, data):
|
|
770
|
+
if corpus.kind_of(uid, data) == "term":
|
|
771
|
+
fields = corpus.term_fields(data)
|
|
772
|
+
aliases = ", ".join(str(a) for a in fields.get("aliases") or [])
|
|
773
|
+
word = corpus.term_word(data) or ""
|
|
774
|
+
return f"{word} ({aliases})" if aliases else word
|
|
775
|
+
if corpus.kind_of(uid, data) in ("requirement", "guard"):
|
|
776
|
+
return " ".join(str(data.get("text") or "").split())
|
|
777
|
+
return str(data.get("name") or "")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def cmd_list(args):
|
|
781
|
+
store, root = corpus.load()
|
|
782
|
+
past = history.History(root, [args.kind] if args.kind else corpus.FOLDERS)
|
|
783
|
+
rows = []
|
|
784
|
+
for item in corpus.items(store):
|
|
785
|
+
uid = str(item.uid)
|
|
786
|
+
data = corpus.raw(item)
|
|
787
|
+
kind = corpus.kind_of(uid, data)
|
|
788
|
+
if args.kind and kind != args.kind:
|
|
789
|
+
continue
|
|
790
|
+
# @req> REQ-82027237@R-XinrGfTNzL fudp2r
|
|
791
|
+
rows.append({"uid": uid, "kind": kind, "status": data.get("status"),
|
|
792
|
+
"last_updated": past.updated(uid, data),
|
|
793
|
+
"handle": _handle(uid, data)})
|
|
794
|
+
rows.sort(key=lambda r: (r["kind"] or "", r["handle"].lower(), r["uid"]))
|
|
795
|
+
|
|
796
|
+
if args.json:
|
|
797
|
+
_emit(args, {"items": rows}, "")
|
|
798
|
+
return EXIT_OK
|
|
799
|
+
if not rows:
|
|
800
|
+
print("no items" + (f" of kind {args.kind}" if args.kind else ""))
|
|
801
|
+
return EXIT_OK
|
|
802
|
+
width = max(len(r["status"] or "") for r in rows)
|
|
803
|
+
dated = max(len(r["last_updated"] or "unknown") for r in rows)
|
|
804
|
+
for row in rows:
|
|
805
|
+
handle = row["handle"]
|
|
806
|
+
if row["kind"] != "term" and len(handle) > 72:
|
|
807
|
+
handle = handle[:71] + "…"
|
|
808
|
+
# @req> REQ-59306961@EGz7Ll3e9FKA 6zyhtu
|
|
809
|
+
# @req> REQ-82027237@R-XinrGfTNzL e6npos
|
|
810
|
+
print(f"{row['uid']} {(row['status'] or ''):<{width}} "
|
|
811
|
+
f"{row['last_updated'] or 'unknown':<{dated}} {handle}")
|
|
812
|
+
return EXIT_OK
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def cmd_validate(args):
|
|
816
|
+
root = corpus.find_root()
|
|
817
|
+
exempted: list[str] = []
|
|
818
|
+
problems = _validate.run(root, exempted)
|
|
819
|
+
count = len(corpus.item_files(root))
|
|
820
|
+
if args.json:
|
|
821
|
+
_emit(args, {"valid": not problems, "items": count, "problems": problems,
|
|
822
|
+
"unclaimed": exempted}, "")
|
|
823
|
+
return EXIT_INVALID if problems else EXIT_OK
|
|
824
|
+
# @req> REQ-88221320@vIuzLHP0hHUo gvnev6
|
|
825
|
+
# @req+ REQ-46373547@54MxhOaz3t8O qu6gxb
|
|
826
|
+
if exempted:
|
|
827
|
+
print("\n".join(exempted) if args.verbose else
|
|
828
|
+
f"{len(exempted)} occurrence(s) accepted unlinked through a "
|
|
829
|
+
"recorded phrase; validate --verbose lists them")
|
|
830
|
+
# @req- qu6gxb
|
|
831
|
+
if problems:
|
|
832
|
+
print("\n".join(problems), file=sys.stderr)
|
|
833
|
+
print(f"\n{len(problems)} problem(s) across {count} item(s)", file=sys.stderr)
|
|
834
|
+
else:
|
|
835
|
+
print(f"corpus valid: {count} item(s)")
|
|
836
|
+
return EXIT_INVALID if problems else EXIT_OK
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def cmd_trace(args):
|
|
840
|
+
tree, root = corpus.load()
|
|
841
|
+
# @req> REQ-27097724@WuK_Hn1OEs1s htlxvr
|
|
842
|
+
data = graph.trace(tree, root, args.uid, args.base, args.record)
|
|
843
|
+
if args.json:
|
|
844
|
+
_emit(args, data, "")
|
|
845
|
+
else:
|
|
846
|
+
for row in data["rows"]:
|
|
847
|
+
print(f"{row['uid']} {row['status']}")
|
|
848
|
+
for path in row["implementation"]:
|
|
849
|
+
print(f" impl {path}")
|
|
850
|
+
# @req> REQ-73027720@2Z15jgYQYfsS iy3utt
|
|
851
|
+
if data["unimplemented"]:
|
|
852
|
+
print("\nawaiting implementation: " + ", ".join(data["unimplemented"]))
|
|
853
|
+
# @req> REQ-35979865@pd7CVJ7isMys wht4dr
|
|
854
|
+
if data["deprecated"]:
|
|
855
|
+
print("\ncited though deprecated")
|
|
856
|
+
for row in data["deprecated"]:
|
|
857
|
+
print(f" {row['uid']} {row['path']}")
|
|
858
|
+
if data["stale"]:
|
|
859
|
+
print("\nbehind the corpus", file=sys.stderr)
|
|
860
|
+
for row in data["stale"]:
|
|
861
|
+
# @req> REQ-24608716@rrTjNN2Ay0D0 zfc5eh
|
|
862
|
+
print(f" {row['uid']} {row['path']} "
|
|
863
|
+
f"pinned @{row['pinned']}, now @{row['held']}"
|
|
864
|
+
+ history.lines(row), file=sys.stderr)
|
|
865
|
+
if data["problems"]:
|
|
866
|
+
print("\n".join(data["problems"]), file=sys.stderr)
|
|
867
|
+
return EXIT_INVALID if data["problems"] or data["stale"] else EXIT_OK
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
# @req> REQ-78685242@0TbO1Puhk4VD da5qxm
|
|
871
|
+
def _listed(store, uid, entries):
|
|
872
|
+
item = corpus.find(store, uid)
|
|
873
|
+
# @req> REQ-88203622@wlZ9orsr9c7q 3xsagz
|
|
874
|
+
if not corpus.citable(item):
|
|
875
|
+
return []
|
|
876
|
+
before = corpus.mapping(item.data, corpus.CITATION_LIST)
|
|
877
|
+
held = dict(before)
|
|
878
|
+
for identity, taken in entries.items():
|
|
879
|
+
if taken is None:
|
|
880
|
+
held.pop(identity, None)
|
|
881
|
+
else:
|
|
882
|
+
held[identity] = taken
|
|
883
|
+
if held == before:
|
|
884
|
+
return []
|
|
885
|
+
if held:
|
|
886
|
+
item.data[corpus.CITATION_LIST] = held
|
|
887
|
+
else:
|
|
888
|
+
item.data.pop(corpus.CITATION_LIST, None)
|
|
889
|
+
return [(item.path, corpus.dump(item.data))]
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# @req> REQ-40478019@_dwqsOpSOQXA pcofn5
|
|
893
|
+
def _whole(tree, root, args):
|
|
894
|
+
target = Path(args.path).resolve()
|
|
895
|
+
try:
|
|
896
|
+
relative = target.relative_to(Path(root).resolve()).as_posix()
|
|
897
|
+
except ValueError as error:
|
|
898
|
+
# @req> REQ-30898926@Vu28eWaG_1mE qgpboq
|
|
899
|
+
raise ReqctlError(f"{args.path}: not in the code base at {root} -- "
|
|
900
|
+
"give a path inside it") from error
|
|
901
|
+
digest = cite.whole(relative, target.read_bytes().decode())
|
|
902
|
+
asked = list(dict.fromkeys(args.req))
|
|
903
|
+
changes = {}
|
|
904
|
+
for req in asked:
|
|
905
|
+
item, entry = corpus.cited_item(tree, req)
|
|
906
|
+
if entry or not corpus.citable(item):
|
|
907
|
+
# @req> REQ-30898926@Vu28eWaG_1mE oiki6a
|
|
908
|
+
raise ReqctlError(f"{req}: a whole-file citation is held by a requirement, "
|
|
909
|
+
"a guard, a parameter or a data item -- name one of "
|
|
910
|
+
"those, not an entry of one, with --req")
|
|
911
|
+
item.data[corpus.WHOLE_FILES] = {
|
|
912
|
+
**corpus.mapping(item.data, corpus.WHOLE_FILES),
|
|
913
|
+
relative: {"pinned": corpus.cited_stamp(item),
|
|
914
|
+
"digest": digest}}
|
|
915
|
+
changes[item.path] = corpus.dump(item.data)
|
|
916
|
+
corpus.write_all(changes.items())
|
|
917
|
+
_emit(args, {"path": relative, "uids": asked},
|
|
918
|
+
f"{relative} cited whole for {', '.join(asked)}")
|
|
919
|
+
return EXIT_OK
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def cmd_tag(args):
|
|
923
|
+
tree, root = corpus.load()
|
|
924
|
+
# @req> REQ-74702510@gU2X5OnlUbY3 hmh22z
|
|
925
|
+
if args.names:
|
|
926
|
+
if args.first or args.last:
|
|
927
|
+
# @req> REQ-30898926@Vu28eWaG_1mE ygm75x
|
|
928
|
+
raise ReqctlError("give each citation a --name or a --from and --to, not both")
|
|
929
|
+
spans = [cite.spanned(args.path, name) for name in args.names]
|
|
930
|
+
args.first, args.last = map(list, zip(*spans))
|
|
931
|
+
# @req> REQ-40478019@_dwqsOpSOQXA jwvxph
|
|
932
|
+
if args.first is None and args.last is None:
|
|
933
|
+
return _whole(tree, root, args)
|
|
934
|
+
# @req+ REQ-62782894@x4o_oB5H0-tY nmyxfc
|
|
935
|
+
if not len(args.first or []) == len(args.last or []) == len(args.req):
|
|
936
|
+
# @req> REQ-30898926@Vu28eWaG_1mE vgjbxe
|
|
937
|
+
raise ReqctlError("name --from and --to, or --name, and --req once for each citation")
|
|
938
|
+
asked = []
|
|
939
|
+
for first, last, req in zip(args.first, args.last, args.req):
|
|
940
|
+
item, entry = corpus.cited_item(tree, req)
|
|
941
|
+
# @req> REQ-88203622@wlZ9orsr9c7q myjx5s
|
|
942
|
+
if not corpus.citable(item):
|
|
943
|
+
# @req> REQ-30898926@Vu28eWaG_1mE h5bqh2
|
|
944
|
+
raise ReqctlError(f"{req}: only a requirement, a guard, a parameter, "
|
|
945
|
+
"a data item or one entry of a data item is cited "
|
|
946
|
+
"-- name one of those with --req")
|
|
947
|
+
# @req> REQ-22755763@lFaaseccSg81 yiibpx
|
|
948
|
+
# @req> REQ-34330878@E2ViDxqgA25l nqvtul
|
|
949
|
+
# @req> REQ-18176935@A6AhLhUZ7MdW wkv6gw
|
|
950
|
+
asked.append((first, last, f"{item.uid}.{entry}" if entry else str(item.uid),
|
|
951
|
+
corpus.cited_stamp(item, entry)))
|
|
952
|
+
change, written = cite.tagged(root, args.path, asked, args.exclusive)
|
|
953
|
+
# @req- nmyxfc
|
|
954
|
+
changes = [change]
|
|
955
|
+
# @req> REQ-75539229@263gd_-RLfVL c7r3ff
|
|
956
|
+
# @req> REQ-89706423@fyVHTsenA51D wrzqdw
|
|
957
|
+
for uid in dict.fromkeys(uid for _, uid, _ in written):
|
|
958
|
+
changes += _listed(tree, uid, {identity: taken
|
|
959
|
+
for identity, named, taken in written
|
|
960
|
+
if named == uid})
|
|
961
|
+
# @req> REQ-65668011@WuPZe9UcmiaP 4pj4jj
|
|
962
|
+
corpus.write_all(changes)
|
|
963
|
+
identities = [identity for identity, _, _ in written]
|
|
964
|
+
_emit(args, {"ids": identities, "path": args.path}, "\n".join(identities))
|
|
965
|
+
return EXIT_OK
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
# @req> REQ-96580262@hSRVhY3ybYl9 r4nj25
|
|
969
|
+
def cmd_repin(args):
|
|
970
|
+
tree, root = corpus.load()
|
|
971
|
+
files, listed, rows = {}, {}, []
|
|
972
|
+
# @req+ REQ-65738797@3jHtqzLVQUal g4qwzc
|
|
973
|
+
for citation, standing in cite.pinning(root, list(dict.fromkeys(args.id))):
|
|
974
|
+
# @req> REQ-57688239@Pv7jzOAUDd-O rmkf77
|
|
975
|
+
item, entry = corpus.cited_item(tree, citation["address"])
|
|
976
|
+
# @req> REQ-70626698@nJ3cW5CgMJtE s45utm
|
|
977
|
+
if corpus.raw(item).get("status") == "deprecated":
|
|
978
|
+
# @req> REQ-30898926@Vu28eWaG_1mE jhlw6y
|
|
979
|
+
raise ReqctlError(f"{citation['uid']} is deprecated -- a citation of it "
|
|
980
|
+
f"is removed, not re-pinned: `reqctl untag {citation['id']}`; "
|
|
981
|
+
"nothing was re-pinned")
|
|
982
|
+
# @req> REQ-57688239@Pv7jzOAUDd-O btnwzg
|
|
983
|
+
# @req> REQ-41600593@dauTiMee9rmb pu2zgs
|
|
984
|
+
stamp = corpus.cited_stamp(item, entry)
|
|
985
|
+
files.setdefault(citation["path"], []).append((citation, stamp))
|
|
986
|
+
# @req> REQ-64846889@TSG5uy3nM0nk 77re7v
|
|
987
|
+
listed.setdefault(citation["uid"], {})[citation["id"]] = standing
|
|
988
|
+
rows.append({"id": citation["id"], "path": citation["path"], "stamp": stamp})
|
|
989
|
+
# @req+ REQ-17757558@XeiCnIpn4RLV nkxtiu
|
|
990
|
+
changes = [cite.repinned(root, pins) for pins in files.values()]
|
|
991
|
+
for uid, entries in listed.items():
|
|
992
|
+
changes += _listed(tree, uid, entries)
|
|
993
|
+
# @req- nkxtiu
|
|
994
|
+
# @req> REQ-65668011@WuPZe9UcmiaP nmk2ra
|
|
995
|
+
corpus.write_all(changes)
|
|
996
|
+
# @req- g4qwzc
|
|
997
|
+
_emit(args, rows[0] if len(rows) == 1 else rows,
|
|
998
|
+
"\n".join(f"{row['id']} in {row['path']} pinned @{row['stamp']}" for row in rows))
|
|
999
|
+
return EXIT_OK
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _unwhole(tree, root, args):
|
|
1003
|
+
try:
|
|
1004
|
+
relative = Path(args.id).resolve().relative_to(
|
|
1005
|
+
Path(root).resolve()).as_posix()
|
|
1006
|
+
except ValueError as error:
|
|
1007
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 2g2etu
|
|
1008
|
+
raise ReqctlError(f"{args.id}: not in the code base at {root} -- "
|
|
1009
|
+
"give a path inside it") from error
|
|
1010
|
+
asked = list(dict.fromkeys(args.req))
|
|
1011
|
+
changes = {}
|
|
1012
|
+
for req in asked:
|
|
1013
|
+
item = corpus.find(tree, req)
|
|
1014
|
+
held = corpus.mapping(item.data, corpus.WHOLE_FILES)
|
|
1015
|
+
# @req> REQ-21009265@yuxuTugeVMu5 p3htis
|
|
1016
|
+
if relative not in held:
|
|
1017
|
+
# @req> REQ-30898926@Vu28eWaG_1mE uxrhvu
|
|
1018
|
+
raise ReqctlError(f"{req} holds no whole-file citation of "
|
|
1019
|
+
f"{relative}; nothing was changed -- "
|
|
1020
|
+
f"reqctl trace {req} lists what it cites")
|
|
1021
|
+
# @req+ REQ-98709103@JryTDKzxpwrF ckhfaq
|
|
1022
|
+
# @req+ REQ-49367452@tPjE-de7pYv_ m6zfum
|
|
1023
|
+
rest = {path: cited for path, cited in held.items() if path != relative}
|
|
1024
|
+
data = {key: value for key, value in item.data.items()
|
|
1025
|
+
if key != corpus.WHOLE_FILES}
|
|
1026
|
+
if rest:
|
|
1027
|
+
data[corpus.WHOLE_FILES] = rest
|
|
1028
|
+
changes[item.path] = corpus.dump(data)
|
|
1029
|
+
# @req- m6zfum
|
|
1030
|
+
# @req- ckhfaq
|
|
1031
|
+
corpus.write_all(changes.items())
|
|
1032
|
+
_emit(args, {"path": relative, "uids": asked},
|
|
1033
|
+
f"{relative} no longer cited whole by {', '.join(asked)}")
|
|
1034
|
+
return EXIT_OK
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def cmd_untag(args):
|
|
1038
|
+
tree, root = corpus.load()
|
|
1039
|
+
# @req> REQ-98709103@JryTDKzxpwrF fzxhli
|
|
1040
|
+
if args.req:
|
|
1041
|
+
return _unwhole(tree, root, args)
|
|
1042
|
+
# @req> REQ-51709712@D0x4hZmSHgiW j65lln
|
|
1043
|
+
citation = cite.named(root, args.id, placed=False)
|
|
1044
|
+
changes = [cite.untagged(root, citation)]
|
|
1045
|
+
# @req> REQ-13298390@Pum5hwaVAaJo utmfsc
|
|
1046
|
+
# @req> REQ-81275367@2q0tIHQCM4br olrtj5
|
|
1047
|
+
# @req> REQ-28888702@sxS9aXY0y0Ed iwdu5n
|
|
1048
|
+
if citation["uid"] in {str(item.uid) for item in corpus.items(tree)}:
|
|
1049
|
+
changes += _listed(tree, citation["uid"], {args.id: None})
|
|
1050
|
+
# @req> REQ-65668011@WuPZe9UcmiaP jwd2cj
|
|
1051
|
+
corpus.write_all(changes)
|
|
1052
|
+
_emit(args, {"id": args.id, "path": citation["path"]},
|
|
1053
|
+
f"{args.id} removed from {citation['path']}")
|
|
1054
|
+
return EXIT_OK
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def cmd_unlist(args):
|
|
1058
|
+
tree, root = corpus.load()
|
|
1059
|
+
item = corpus.find(tree, args.uid)
|
|
1060
|
+
uid = str(item.uid)
|
|
1061
|
+
# @req> REQ-97939970@lyVUGGzeSEQW xrna4u
|
|
1062
|
+
# @req> REQ-44322088@sDoz9XZ22EBj 7e5dqc
|
|
1063
|
+
if (not corpus.citable(item)
|
|
1064
|
+
or args.id not in corpus.mapping(item.data, corpus.CITATION_LIST)):
|
|
1065
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 2x7oac
|
|
1066
|
+
raise ReqctlError(f"{uid}: its citation list does not hold {args.id} -- "
|
|
1067
|
+
"reqctl trace names the item whose list holds it")
|
|
1068
|
+
# @req+ REQ-59136977@-tsi9nTBSL9- s3g7ya
|
|
1069
|
+
# @req> REQ-81063063@v5hfLK63eQj_ rphl5n
|
|
1070
|
+
# @req> REQ-62685242@nMlCr6nshoNZ cwxoe2
|
|
1071
|
+
if any(citation["uid"] == uid for citation in cite.readable(root, args.id)):
|
|
1072
|
+
# @req> REQ-30898926@Vu28eWaG_1mE pkwvbp
|
|
1073
|
+
raise ReqctlError(
|
|
1074
|
+
f"{uid}: a statement citation names {args.id} -- reqctl untag "
|
|
1075
|
+
f"{args.id} removes the citation, reqctl repin {args.id} re-pins it")
|
|
1076
|
+
# @req> REQ-56797974@xJHqq1DXePrs tn22pk
|
|
1077
|
+
corpus.write_all(_listed(tree, uid, {args.id: None}))
|
|
1078
|
+
# @req- s3g7ya
|
|
1079
|
+
_emit(args, {"uid": uid, "id": args.id},
|
|
1080
|
+
f"{args.id} removed from the citation list of {uid}")
|
|
1081
|
+
return EXIT_OK
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def cmd_compare(args):
|
|
1085
|
+
root = corpus.find_root()
|
|
1086
|
+
ref = args.base or _baseline._default_ref(root)
|
|
1087
|
+
if ref is None:
|
|
1088
|
+
# @req> REQ-30898926@Vu28eWaG_1mE gl353f
|
|
1089
|
+
raise ReqctlError("no origin to compare against -- name one with --base")
|
|
1090
|
+
data = cite.compare(root, ref)
|
|
1091
|
+
lines = [f"{row['id']} {row['uid']} {row['path']} {', '.join(row['state'])}"
|
|
1092
|
+
for row in data["citations"]]
|
|
1093
|
+
# @req> REQ-33614084@XHPdW0ynnY4c kiiwp5
|
|
1094
|
+
lines += [f"whole {row['uid']} {row['path']} {', '.join(row['state'])}"
|
|
1095
|
+
for row in data["files"]]
|
|
1096
|
+
lines.append("touches: " + (", ".join(data["touches"]) or "nothing"))
|
|
1097
|
+
_emit(args, data, "\n".join(lines))
|
|
1098
|
+
return EXIT_OK
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
# @req> REQ-49576265@pFihs7WdIwGS pihyx4
|
|
1102
|
+
def _opened(url):
|
|
1103
|
+
if not webbrowser.open(url):
|
|
1104
|
+
print(f"no browser opened; open {url} in one", file=sys.stderr)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def cmd_portal(args):
|
|
1108
|
+
held = _portal.repository(os.getcwd())
|
|
1109
|
+
# @req+ REQ-49576265@pFihs7WdIwGS wx5icq
|
|
1110
|
+
server = _portal.server()
|
|
1111
|
+
# @req> REQ-53764133@s638VvRMfEgi f7ob5l
|
|
1112
|
+
url = f"http://{_portal.HOST}:{_portal.PORT}/?repo={held}"
|
|
1113
|
+
_emit(args, {"repository": held, "url": url},
|
|
1114
|
+
f"the portal on {held} is at {url} -- Ctrl-C stops it")
|
|
1115
|
+
sys.stdout.flush()
|
|
1116
|
+
threading.Thread(target=_opened, args=(url,), daemon=True).start()
|
|
1117
|
+
try:
|
|
1118
|
+
server.serve_forever()
|
|
1119
|
+
except KeyboardInterrupt:
|
|
1120
|
+
pass
|
|
1121
|
+
finally:
|
|
1122
|
+
server.server_close()
|
|
1123
|
+
# @req- wx5icq
|
|
1124
|
+
return EXIT_OK
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def cmd_resolve(args):
|
|
1128
|
+
store, _ = corpus.load()
|
|
1129
|
+
path, dropped, unlisted = _resolve.resolve(store, args.uid)
|
|
1130
|
+
said = f"{args.uid}: resolved"
|
|
1131
|
+
if dropped:
|
|
1132
|
+
acks = " ".join(f"--ack {gone}" for gone in dropped)
|
|
1133
|
+
said += (f"; the merge conflicted {', '.join(dropped)}, now suspect "
|
|
1134
|
+
f"-- reread, then reqctl revise {args.uid} {acks}")
|
|
1135
|
+
if unlisted:
|
|
1136
|
+
said += (f"; the merge conflicted citations {', '.join(unlisted)}, now "
|
|
1137
|
+
f"unlisted -- read each against {args.uid}, then reqctl repin "
|
|
1138
|
+
"it")
|
|
1139
|
+
_emit(args, {"uid": args.uid, "path": str(path), "dropped": dropped,
|
|
1140
|
+
"unlisted": unlisted}, said)
|
|
1141
|
+
return EXIT_OK
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def cmd_baseline(args):
|
|
1145
|
+
tree, root = corpus.load()
|
|
1146
|
+
|
|
1147
|
+
if args.generate:
|
|
1148
|
+
# @req> REQ-64286515@d0Nt8dNubZNP nb26v5
|
|
1149
|
+
_refuse_invalid(root, "generate")
|
|
1150
|
+
number, path, count, claimed = _baseline.generate(tree, root)
|
|
1151
|
+
said = (
|
|
1152
|
+
f"baseline {number} written with {count} approved item(s).\n"
|
|
1153
|
+
"It states what is approved; it does not approve anything."
|
|
1154
|
+
)
|
|
1155
|
+
# @req> REQ-28086776@7S0DPOiDLvSm dlppjs
|
|
1156
|
+
if claimed:
|
|
1157
|
+
stated = " and ".join(str(one) for one in sorted(set(claimed)))
|
|
1158
|
+
said = (
|
|
1159
|
+
f"an unresolved merge claiming {stated} was regenerated rather "
|
|
1160
|
+
"than merged.\n" + said
|
|
1161
|
+
)
|
|
1162
|
+
_emit(
|
|
1163
|
+
args,
|
|
1164
|
+
{"baseline": number, "path": str(path), "items": count,
|
|
1165
|
+
"resolved": claimed},
|
|
1166
|
+
said,
|
|
1167
|
+
)
|
|
1168
|
+
return EXIT_OK
|
|
1169
|
+
|
|
1170
|
+
number, problems = _baseline.check(tree, root)
|
|
1171
|
+
if args.json:
|
|
1172
|
+
_emit(args, {"baseline": number, "valid": not problems, "problems": problems}, "")
|
|
1173
|
+
elif problems:
|
|
1174
|
+
print("\n".join(problems), file=sys.stderr)
|
|
1175
|
+
if number is not None:
|
|
1176
|
+
print(f"\nbaseline {number} does not match the corpus", file=sys.stderr)
|
|
1177
|
+
elif number is None:
|
|
1178
|
+
print("no baseline yet")
|
|
1179
|
+
else:
|
|
1180
|
+
print(f"baseline {number} matches the corpus")
|
|
1181
|
+
return EXIT_INVALID if problems else EXIT_OK
|
|
1182
|
+
|
|
1183
|
+
def _command(sub, name, said):
|
|
1184
|
+
return sub.add_parser(name, help=said, description=said)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
RESERVED = ("json", "command", "func", "uid", "kind")
|
|
1188
|
+
FIELDED = ("new", "lint", "revise")
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _field_arguments(s, flags, taken=()):
|
|
1192
|
+
for flag in flags:
|
|
1193
|
+
if flag.dest in taken and flag.entries:
|
|
1194
|
+
continue
|
|
1195
|
+
if flag.dest in RESERVED + tuple(taken):
|
|
1196
|
+
# @req> REQ-30898926@Vu28eWaG_1mE 5w6stc
|
|
1197
|
+
raise ReqctlError(f"a kind schema gives a field {flag.flag}, which "
|
|
1198
|
+
"reqctl takes for its own; mark the field readOnly "
|
|
1199
|
+
"if tooling writes it, or give it another x-flag")
|
|
1200
|
+
try:
|
|
1201
|
+
if flag.switch:
|
|
1202
|
+
s.add_argument(flag.flag, action="store_true", default=None)
|
|
1203
|
+
else:
|
|
1204
|
+
s.add_argument(flag.flag, choices=list(flag.choices) or None,
|
|
1205
|
+
action="append" if flag.repeated else "store",
|
|
1206
|
+
type=flag.convert, metavar=flag.metavar)
|
|
1207
|
+
except argparse.ArgumentError as clash:
|
|
1208
|
+
# @req> REQ-30898926@Vu28eWaG_1mE jvhvno
|
|
1209
|
+
raise ReqctlError(f"a kind schema gives a field {flag.flag}, which "
|
|
1210
|
+
f"reqctl takes for its own: {clash} -- give the "
|
|
1211
|
+
"field another x-flag in its kind schema") from None
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _item_arguments(s, flags):
|
|
1215
|
+
s.add_argument("kind", choices=list(_write.KINDS))
|
|
1216
|
+
# @req> REQ-71965656@F8ulVugEMpD- f7bkcf
|
|
1217
|
+
_field_arguments(s, flags)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _fielded(argv):
|
|
1221
|
+
return next((token for token in argv if not token.startswith("-")),
|
|
1222
|
+
None) in FIELDED
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _value_typed(root, flags):
|
|
1226
|
+
# @req+ REQ-58926409@zt0Kl436tNXd 54mksz
|
|
1227
|
+
store, _ = corpus.load(root)
|
|
1228
|
+
corpus.items(store)
|
|
1229
|
+
try:
|
|
1230
|
+
listed = corpus.find(store, _validate.VALUE_TYPES)
|
|
1231
|
+
except ReqctlError:
|
|
1232
|
+
return flags
|
|
1233
|
+
if corpus.kind_of(listed.uid, listed.data) != "data":
|
|
1234
|
+
return flags
|
|
1235
|
+
held = tuple(str(key) for key in corpus.entries(listed.data) or {})
|
|
1236
|
+
return [dataclasses.replace(flag, choices=held)
|
|
1237
|
+
if flag.dest == "value_type" else flag for flag in flags]
|
|
1238
|
+
# @req- 54mksz
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def build_parser(root, fielded):
|
|
1242
|
+
flags = _value_typed(root, _fields.flags(root)) if fielded else []
|
|
1243
|
+
clears = _fields.clears(root) if fielded else []
|
|
1244
|
+
p = argparse.ArgumentParser(
|
|
1245
|
+
prog="reqctl",
|
|
1246
|
+
description="The requirements corpus under requirements/ is read and "
|
|
1247
|
+
"written only through these commands. --json goes before "
|
|
1248
|
+
"the command: reqctl --json <command> ...",
|
|
1249
|
+
)
|
|
1250
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1251
|
+
# @req> REQ-94206537@I4xcLLW1nbOH 47la7c
|
|
1252
|
+
p.add_argument("--version", action="version",
|
|
1253
|
+
version=importlib.metadata.version("reqctl"))
|
|
1254
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
1255
|
+
|
|
1256
|
+
s = _command(sub, "new", "mint a requirement, parameter, term or data item")
|
|
1257
|
+
_item_arguments(s, flags)
|
|
1258
|
+
s.add_argument("--uid", help="mint a requirement or guard under this uid "
|
|
1259
|
+
"rather than a fresh one")
|
|
1260
|
+
s.set_defaults(func=cmd_new)
|
|
1261
|
+
|
|
1262
|
+
s = _command(sub, "lint",
|
|
1263
|
+
"report the faults new would refuse an item for, writing "
|
|
1264
|
+
"nothing")
|
|
1265
|
+
_item_arguments(s, flags)
|
|
1266
|
+
s.set_defaults(func=cmd_lint)
|
|
1267
|
+
|
|
1268
|
+
s = _command(sub, "revise",
|
|
1269
|
+
"change an item's fields, pin its links with --ack, or edit "
|
|
1270
|
+
"a data entry")
|
|
1271
|
+
# @req> REQ-94401451@EZFY5LjHcj9g znwuir
|
|
1272
|
+
s.add_argument("uid", nargs="+")
|
|
1273
|
+
# @req> REQ-91205530@FiUaykViw6dQ ctnzp2
|
|
1274
|
+
_field_arguments(s, flags + clears, _write.OPERATIONS)
|
|
1275
|
+
s.add_argument("--ack", dest="ack", action="append",
|
|
1276
|
+
metavar="NAME | NAME.ENTRY",
|
|
1277
|
+
help="pin a referenced parameter or linked term at its "
|
|
1278
|
+
"current state; repeat for each one")
|
|
1279
|
+
s.add_argument("--handle", metavar="KEY",
|
|
1280
|
+
help="terms: the entry key, snake_case; the word it wore "
|
|
1281
|
+
"moves into the entry where a word is not already "
|
|
1282
|
+
"stated")
|
|
1283
|
+
s.add_argument("--kind", choices=list(_write.KINDS))
|
|
1284
|
+
s.add_argument("--entry", metavar="KEY",
|
|
1285
|
+
help="the existing entry --set writes to")
|
|
1286
|
+
s.add_argument("--new-entry", dest="new_entry", metavar="KEY",
|
|
1287
|
+
help="add a data entry, applying --set to it")
|
|
1288
|
+
s.add_argument("--set", dest="set", action="append", metavar="FIELD=VALUE",
|
|
1289
|
+
help="a field on --entry's entry; repeat for each one")
|
|
1290
|
+
s.add_argument("--append", dest="append", action="append",
|
|
1291
|
+
metavar="FIELD=VALUE",
|
|
1292
|
+
help="add one member to a set field; repeat for each member")
|
|
1293
|
+
s.add_argument("--unset", dest="unset", action="append", metavar="FIELD",
|
|
1294
|
+
help="drop a field from --entry's entry; repeat for each one")
|
|
1295
|
+
s.add_argument("--drop-entry", dest="drop_entry", metavar="KEY",
|
|
1296
|
+
help="remove a data entry; its fields go with it")
|
|
1297
|
+
s.add_argument("--reword", dest="reword", action="append", metavar="OLD=NEW",
|
|
1298
|
+
help="a word this term's links show, and what it becomes; "
|
|
1299
|
+
"--term rewrites its own word without this")
|
|
1300
|
+
s.set_defaults(func=cmd_revise)
|
|
1301
|
+
|
|
1302
|
+
s = _command(sub, "relate",
|
|
1303
|
+
"record a relation between requirements, from the statement "
|
|
1304
|
+
"making the claim toward the one it leans on")
|
|
1305
|
+
s.add_argument("source", help="the requirement making the claim")
|
|
1306
|
+
s.add_argument("relation", choices=list(_write.RELATIONS))
|
|
1307
|
+
s.add_argument("target", help="the requirement it points at")
|
|
1308
|
+
s.set_defaults(func=cmd_relate)
|
|
1309
|
+
|
|
1310
|
+
s = _command(sub, "repin-links",
|
|
1311
|
+
"pin every link and reference awaiting assessment in the items "
|
|
1312
|
+
"named, or with --all in every item, checking the corpus once")
|
|
1313
|
+
s.add_argument("uids", nargs="*", metavar="UID")
|
|
1314
|
+
s.add_argument("--all", action="store_true")
|
|
1315
|
+
s.set_defaults(func=cmd_repin_links)
|
|
1316
|
+
|
|
1317
|
+
s = _command(sub, "unrelate", "remove a relation")
|
|
1318
|
+
s.add_argument("source")
|
|
1319
|
+
s.add_argument("target")
|
|
1320
|
+
s.set_defaults(func=cmd_unrelate)
|
|
1321
|
+
|
|
1322
|
+
s = _command(sub, "rename",
|
|
1323
|
+
"give a term, a parameter or a data item another name, "
|
|
1324
|
+
"moving its file and every reference that reaches it")
|
|
1325
|
+
s.add_argument("uid")
|
|
1326
|
+
s.add_argument("name")
|
|
1327
|
+
s.set_defaults(func=cmd_rename)
|
|
1328
|
+
|
|
1329
|
+
s = _command(sub, "refile",
|
|
1330
|
+
"file a parameter as the data item it is, moving its file "
|
|
1331
|
+
"and dropping the value_type a data item does not carry; "
|
|
1332
|
+
"with --into DATA KEY, as an entry of DATA under KEY instead")
|
|
1333
|
+
s.add_argument("uid")
|
|
1334
|
+
s.add_argument("--into", nargs=2, metavar=("DATA", "KEY"))
|
|
1335
|
+
s.set_defaults(func=cmd_refile)
|
|
1336
|
+
|
|
1337
|
+
s = _command(sub, "delete",
|
|
1338
|
+
"remove a draft nothing points at; an item the corpus has "
|
|
1339
|
+
"stated is deprecated, never removed")
|
|
1340
|
+
s.add_argument("uid")
|
|
1341
|
+
s.set_defaults(func=cmd_delete)
|
|
1342
|
+
|
|
1343
|
+
s = _command(sub, "export",
|
|
1344
|
+
"print the corpus as markdown, or raw with --json")
|
|
1345
|
+
s.add_argument("--all", action="store_true", help="include drafts and deprecated")
|
|
1346
|
+
s.set_defaults(func=cmd_export)
|
|
1347
|
+
|
|
1348
|
+
s = _command(sub, "context",
|
|
1349
|
+
"everything around each item named: fields, links, suspicion, "
|
|
1350
|
+
"code. Inspection only -- what it cannot read it marks, and it "
|
|
1351
|
+
"fails only when nothing named resolves or the scan for code "
|
|
1352
|
+
"citations meets a file or folder it cannot read")
|
|
1353
|
+
s.add_argument("uid", nargs="+", metavar="UID")
|
|
1354
|
+
s.set_defaults(func=cmd_context)
|
|
1355
|
+
|
|
1356
|
+
s = _command(sub, "list",
|
|
1357
|
+
"one line per item -- what the corpus holds, without "
|
|
1358
|
+
"reading all of it")
|
|
1359
|
+
s.add_argument("kind", nargs="?",
|
|
1360
|
+
choices=list(_write.KINDS),
|
|
1361
|
+
help="limit to one kind; omit for everything")
|
|
1362
|
+
s.set_defaults(func=cmd_list)
|
|
1363
|
+
|
|
1364
|
+
s = _command(sub, "validate",
|
|
1365
|
+
"check every item and every cross-link; exit 1 on problems")
|
|
1366
|
+
s.add_argument("--verbose", action="store_true")
|
|
1367
|
+
s.set_defaults(func=cmd_validate)
|
|
1368
|
+
|
|
1369
|
+
s = _command(sub, "trace",
|
|
1370
|
+
"map @req tags in code to the corpus; exit 1 on problems")
|
|
1371
|
+
s.add_argument("uid", nargs="?", help="limit the trace to one item")
|
|
1372
|
+
# @req> REQ-27097724@WuK_Hn1OEs1s updzi2
|
|
1373
|
+
s.add_argument("--base")
|
|
1374
|
+
# @req> REQ-21635723@CvxpeHCRTgGz rji7bg
|
|
1375
|
+
s.add_argument("--record", action="store_true")
|
|
1376
|
+
s.set_defaults(func=cmd_trace)
|
|
1377
|
+
|
|
1378
|
+
s = _command(sub, "tag",
|
|
1379
|
+
"cite a requirement, a guard, a parameter, a data item or one "
|
|
1380
|
+
"entry of a data item at lines of a file, writing one comment "
|
|
1381
|
+
"over a single code statement and a pair of comments over "
|
|
1382
|
+
"more; repeat --from, --to and --req to cite several, each "
|
|
1383
|
+
"numbered as the file stands before the command; --name a "
|
|
1384
|
+
"function, class or method of a .py file in place of --from and "
|
|
1385
|
+
"--to; name none of them to cite a whole file")
|
|
1386
|
+
s.add_argument("path")
|
|
1387
|
+
# @req+ REQ-62782894@x4o_oB5H0-tY ou2zvt
|
|
1388
|
+
s.add_argument("--from", dest="first", type=int, action="append")
|
|
1389
|
+
s.add_argument("--to", dest="last", type=int, action="append")
|
|
1390
|
+
s.add_argument("--req", action="append", required=True)
|
|
1391
|
+
# @req- ou2zvt
|
|
1392
|
+
# @req> REQ-74702510@gU2X5OnlUbY3 ntl5o7
|
|
1393
|
+
s.add_argument("--name", dest="names", action="append")
|
|
1394
|
+
s.add_argument("--exclusive", action="store_true")
|
|
1395
|
+
s.set_defaults(func=cmd_tag)
|
|
1396
|
+
|
|
1397
|
+
s = _command(sub, "repin",
|
|
1398
|
+
"re-pin citations to the stamp their items carry now, "
|
|
1399
|
+
"once the code is read against the statement that stands")
|
|
1400
|
+
s.add_argument("id", nargs="+")
|
|
1401
|
+
s.set_defaults(func=cmd_repin)
|
|
1402
|
+
|
|
1403
|
+
s = _command(sub, "untag",
|
|
1404
|
+
"remove a citation; name a path and --req to remove that "
|
|
1405
|
+
"item's whole-file citation of the path")
|
|
1406
|
+
s.add_argument("id", metavar="ID|PATH")
|
|
1407
|
+
s.add_argument("--req", action="append")
|
|
1408
|
+
s.set_defaults(func=cmd_untag)
|
|
1409
|
+
|
|
1410
|
+
s = _command(sub, "unlist",
|
|
1411
|
+
"remove from an item's citation list an identity that no "
|
|
1412
|
+
"citation of that item names")
|
|
1413
|
+
s.add_argument("uid")
|
|
1414
|
+
s.add_argument("id")
|
|
1415
|
+
s.set_defaults(func=cmd_unlist)
|
|
1416
|
+
|
|
1417
|
+
s = _command(sub, "compare",
|
|
1418
|
+
"state which citations this branch added, deleted, moved or "
|
|
1419
|
+
"changed since it left the default branch, and the items "
|
|
1420
|
+
"they name")
|
|
1421
|
+
s.add_argument("--base")
|
|
1422
|
+
s.set_defaults(func=cmd_compare)
|
|
1423
|
+
|
|
1424
|
+
s = _command(sub, "portal",
|
|
1425
|
+
"serve the portal on the repository origin names, and open "
|
|
1426
|
+
"the browser at it")
|
|
1427
|
+
s.set_defaults(func=cmd_portal)
|
|
1428
|
+
|
|
1429
|
+
s = _command(sub, "resolve",
|
|
1430
|
+
"settle an item whose unresolved merge falls within its "
|
|
1431
|
+
"pins and citation list, dropping the pins and citations the "
|
|
1432
|
+
"merge conflicted")
|
|
1433
|
+
s.add_argument("uid")
|
|
1434
|
+
s.set_defaults(func=cmd_resolve)
|
|
1435
|
+
|
|
1436
|
+
s = _command(sub, "baseline", "the manifest of what is approved")
|
|
1437
|
+
g = s.add_mutually_exclusive_group(required=True)
|
|
1438
|
+
g.add_argument("--check", action="store_true",
|
|
1439
|
+
help="compare the baseline against the corpus")
|
|
1440
|
+
g.add_argument("--generate", action="store_true",
|
|
1441
|
+
help="write the next baseline from what is approved")
|
|
1442
|
+
s.set_defaults(func=cmd_baseline)
|
|
1443
|
+
|
|
1444
|
+
return p
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _drop_output():
|
|
1448
|
+
try:
|
|
1449
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
1450
|
+
except (OSError, ValueError):
|
|
1451
|
+
pass
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
# @req> REQ-61484030@LpHoz6FzkKeT s75lcl
|
|
1455
|
+
# @req> REQ-19913588@hBNJrpn4vtZe irna4n
|
|
1456
|
+
def _fault(as_json, error):
|
|
1457
|
+
try:
|
|
1458
|
+
if sys.stdout is not None:
|
|
1459
|
+
if as_json:
|
|
1460
|
+
print(json.dumps({"error": str(error)}, indent=2), flush=True)
|
|
1461
|
+
return EXIT_INVALID
|
|
1462
|
+
sys.stdout.flush()
|
|
1463
|
+
except OSError:
|
|
1464
|
+
_drop_output()
|
|
1465
|
+
print(f"reqctl: {error}", file=sys.stderr)
|
|
1466
|
+
return EXIT_INVALID
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
# @req> REQ-19913588@hBNJrpn4vtZe dsaqmg
|
|
1470
|
+
def main(argv=None):
|
|
1471
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1472
|
+
# @req+ REQ-19913588@hBNJrpn4vtZe zh6xly
|
|
1473
|
+
try:
|
|
1474
|
+
parser = build_parser(_fields.root(), _fielded(argv))
|
|
1475
|
+
except (ReqctlError, OSError, UnicodeError) as error:
|
|
1476
|
+
return _fault("--json" in argv, error)
|
|
1477
|
+
# @req- zh6xly
|
|
1478
|
+
# @req+ REQ-30898926@Vu28eWaG_1mE wwtx5y
|
|
1479
|
+
said = io.StringIO()
|
|
1480
|
+
try:
|
|
1481
|
+
with contextlib.redirect_stderr(said):
|
|
1482
|
+
args = parser.parse_args(argv)
|
|
1483
|
+
except SystemExit as usage:
|
|
1484
|
+
# @req> REQ-61484030@LpHoz6FzkKeT 7acuon
|
|
1485
|
+
if "--json" in argv and usage.code and sys.stdout is not None:
|
|
1486
|
+
try:
|
|
1487
|
+
print(json.dumps({"error": said.getvalue().strip()}, indent=2),
|
|
1488
|
+
flush=True)
|
|
1489
|
+
raise
|
|
1490
|
+
except OSError:
|
|
1491
|
+
_drop_output()
|
|
1492
|
+
with contextlib.suppress(AttributeError, OSError):
|
|
1493
|
+
sys.stderr.write(said.getvalue())
|
|
1494
|
+
raise
|
|
1495
|
+
# @req- wwtx5y
|
|
1496
|
+
# @req+ REQ-19913588@hBNJrpn4vtZe dw57fs
|
|
1497
|
+
try:
|
|
1498
|
+
# @req> REQ-51372579@dWaIOhXJzQnK 7fex4d
|
|
1499
|
+
if sys.stdout is None:
|
|
1500
|
+
reader, writer = os.pipe()
|
|
1501
|
+
os.close(reader)
|
|
1502
|
+
sys.stdout = os.fdopen(writer, "w")
|
|
1503
|
+
# @req> REQ-24406170@ffuKefeAdU7p 3ofubr
|
|
1504
|
+
with corpus.all_or_nothing():
|
|
1505
|
+
done = args.func(args)
|
|
1506
|
+
sys.stdout.flush()
|
|
1507
|
+
return done
|
|
1508
|
+
except BrokenPipeError:
|
|
1509
|
+
_drop_output()
|
|
1510
|
+
# @req> REQ-51372579@dWaIOhXJzQnK ihfdn7
|
|
1511
|
+
return EXIT_INVALID
|
|
1512
|
+
except (ReqctlError, OSError, UnicodeError) as error:
|
|
1513
|
+
return _fault(args.json, error)
|
|
1514
|
+
# @req- dw57fs
|