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