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/cite.py ADDED
@@ -0,0 +1,1229 @@
1
+ import ast
2
+ import difflib
3
+ import functools
4
+ import io
5
+ import os
6
+ import subprocess
7
+ import re
8
+ import secrets
9
+ import stat
10
+ import tokenize
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from . import corpus, typescript
16
+ from .corpus import ReqctlError
17
+ from .reach import SCRIPTED, Reach, remarked
18
+
19
+ OPEN, CLOSE, SINGLE = "+", "-", ">"
20
+ EVERY = object()
21
+ SIGN = re.compile(r"@req([+>-])(?=\s|$)")
22
+ TIGHT = re.compile(r"@req[+>-](?=\S)")
23
+ # @req> REQ-88203622@wlZ9orsr9c7q y644wh
24
+ # @req> REQ-22755763@lFaaseccSg81 5upe6v
25
+ MARKER = re.compile(
26
+ rf"\A@req(?P<sign>[+>-])(?:\s+(?P<uid>(?:{corpus.KINDS})-\d+"
27
+ rf"|{corpus.NAME}(?:\.{corpus.NAME})?(?=@))"
28
+ r"(?:@(?P<stamp>\S*))?)?(?:\s+(?P<id>\S+))?(?:\s+(?P<exclusive>exclusive))?\Z")
29
+ FORMER = re.compile(rf"@req:\s*((?:{corpus.KINDS})-\d+)")
30
+ # @req+ REQ-52925332@8dtaEN9KH9jO tup4w2
31
+ MARKUP = re.compile(r"\A\s*<!--\s*|\s*-->\s*\Z")
32
+ # @req> REQ-38033048@mKGzPYjyS_wD nvsgih
33
+ # @req> REQ-21225852@RMA1Qtug7rzF kjbkap
34
+ DELIMITERS = {**dict.fromkeys((".py", ".yml", ".yaml"), re.compile(r"\A\s*#+\s*")),
35
+ **dict.fromkeys((".js", *typescript.SUFFIXES), re.compile(r"\A\s*//\s*")),
36
+ ".sql": re.compile(r"\A\s*--\s*")}
37
+ # @req- tup4w2
38
+ ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
39
+ # @req> REQ-60346603@FI4Rjvk1iDNp ulk4ve
40
+ # @req> REQ-38033048@mKGzPYjyS_wD 4ei5xf
41
+ # @req> REQ-21225852@RMA1Qtug7rzF e7bycn
42
+ COMMENTS = {**dict.fromkeys((".py", ".yml", ".yaml"), ("# ", "")),
43
+ ".md": ("<!-- ", " -->"), ".html": ("<!-- ", " -->"),
44
+ **dict.fromkeys((".js", *typescript.SUFFIXES), ("// ", "")), ".sql": ("-- ", "")}
45
+ BOM = "\ufeff"
46
+
47
+
48
+ # @req> REQ-52925332@8dtaEN9KH9jO cczml5
49
+ def _feed_lines(text):
50
+ return io.StringIO(text).readlines()
51
+
52
+
53
+ @functools.cache
54
+ def _front(text):
55
+ lines = _feed_lines(text)
56
+ if not lines or lines[0].rstrip("\r\n") != "---":
57
+ return 0
58
+ return next((number for number, line in enumerate(lines[1:], start=2)
59
+ if line.rstrip("\r\n") == "---"), 0)
60
+
61
+
62
+ @functools.cache
63
+ def _matter(text):
64
+ close = _front(text)
65
+ return "".join(line if 1 < number < close else _ending(line)
66
+ for number, line in enumerate(_feed_lines(text), start=1))
67
+
68
+
69
+ def _view(suffix, text, number):
70
+ if suffix == ".md" and 1 < number < _front(text):
71
+ return ".yml", _matter(text)
72
+ return suffix, text
73
+
74
+
75
+ def markers(text, suffix):
76
+ for number, line in enumerate(_feed_lines(text), start=1):
77
+ # @req+ REQ-52925332@8dtaEN9KH9jO yuyqhd
78
+ # @req+ REQ-12490145@aKVRSLNQy976 dsvgt4
79
+ if not SIGN.search(line):
80
+ continue
81
+ # @req> REQ-35587066@UWrbgGEbefJg g2x6cn
82
+ body = DELIMITERS.get(_view(suffix, text, number)[0], MARKUP).sub("", line).strip()
83
+ yield number, body, MARKER.match(body)
84
+ # @req- dsvgt4
85
+ # @req- yuyqhd
86
+
87
+
88
+ @functools.cache
89
+ def _depths(text):
90
+ depth, depths, starts, ends = 0, {}, set(), set()
91
+ fresh = True
92
+ for token in tokenize.generate_tokens(io.StringIO(text).readline):
93
+ kind, line = token.type, token.start[0]
94
+ if kind == tokenize.INDENT:
95
+ depth += 1
96
+ elif kind == tokenize.DEDENT:
97
+ depth -= 1
98
+ elif kind == tokenize.NEWLINE:
99
+ ends.add(line)
100
+ fresh = True
101
+ elif kind not in (tokenize.NL, tokenize.COMMENT, tokenize.ENDMARKER):
102
+ for spanned in range(token.start[0], token.end[0] + 1):
103
+ depths.setdefault(spanned, depth)
104
+ if fresh:
105
+ starts.add(line)
106
+ fresh = False
107
+ return depths, starts, ends
108
+
109
+
110
+ @functools.cache
111
+ def _indents(text):
112
+ spans, flows = [], []
113
+ for event in yaml.parse(text, Loader=corpus.Loader):
114
+ start, end = event.start_mark, event.end_mark
115
+ if isinstance(event, yaml.CollectionStartEvent) and event.flow_style:
116
+ flows.append(start)
117
+ continue
118
+ if isinstance(event, yaml.CollectionEndEvent) and flows:
119
+ start = flows.pop()
120
+ spans.append(range(start.line + 1, end.line + 1 + bool(end.column)))
121
+ inside = {n for span in spans for n in span[1:]}
122
+ depths = {}
123
+ for number, line in enumerate(_feed_lines(text), start=1):
124
+ body = line.strip()
125
+ if body and (number in inside or not body.startswith("#")):
126
+ depths[number] = len(_indent(line)) + (body == "-" or body.startswith("- "))
127
+ ends = set(depths)
128
+ for span in spans:
129
+ ends.difference_update([n for n in span if n in depths][:-1])
130
+ return depths, set(depths) - inside, ends
131
+
132
+
133
+ NESTS = {".py": _depths, ".yml": _indents, ".yaml": _indents}
134
+
135
+
136
+ def cut(path, text, spans):
137
+ suffix = Path(path).suffix
138
+ problems = []
139
+ # @req> REQ-55291596@DLuklmxajzSV 5hrvsm
140
+ if suffix == ".md":
141
+ close = _front(text)
142
+ problems = [(identity, f"{path}: citation {identity} crosses the edge of the "
143
+ "front matter -- it must lie between its --- lines")
144
+ for identity, first, last in spans if 1 < first - 1 < close <= last]
145
+ spans = [span for span in spans if 1 < span[1] and span[2] < close]
146
+ if not spans:
147
+ return problems
148
+ suffix, text = ".yml", _matter(text)
149
+ # @req+ REQ-60833823@pdX0Xr4bVNDt afaqnn
150
+ # @req+ REQ-20885376@eU3vxq0uOnB- dzfzmi
151
+ row = (typescript.read(suffix, typescript.blanked(text, _marked(text))).broken
152
+ if suffix in typescript.SUFFIXES else _sql(text)[1] if suffix == ".sql" else None)
153
+ if row:
154
+ # @req> REQ-30898926@Vu28eWaG_1mE ryp2vk
155
+ return [(path, f"{path}: cannot read it past line {row}, so no citation in it can "
156
+ "be checked -- fix the file so it parses, then retry")]
157
+ # @req- dzfzmi
158
+ # @req- afaqnn
159
+ read = NESTS.get(suffix)
160
+ if read is None:
161
+ return []
162
+ try:
163
+ depths, starts, ends = read(text)
164
+ except (tokenize.TokenError, IndentationError, SyntaxError, yaml.YAMLError) as broken:
165
+ # @req> REQ-30898926@Vu28eWaG_1mE 4cfehc
166
+ return problems + [(path, f"{path}: cannot read its nest levels ({broken}), so no "
167
+ "citation in it can be checked -- fix the file so "
168
+ "its nest levels parse, then retry")]
169
+ for identity, first, last in spans:
170
+ code = [n for n in range(first, last + 1) if n in depths]
171
+ if not code:
172
+ continue
173
+ level = depths[code[0]]
174
+ after = [n for n in depths if n > last]
175
+ # @req> REQ-41552912@G_9UzXwu1WUL fu7y6n
176
+ if (code[0] not in starts or code[-1] not in ends
177
+ or any(depths[n] < level for n in code)
178
+ or (after and depths[min(after)] > level)):
179
+ # @req> REQ-30898926@Vu28eWaG_1mE whom5u
180
+ problems.append((identity,
181
+ f"{path}: citation {identity} cuts a block, as it must "
182
+ "cover whole statements at the nest level of its first "
183
+ f"line -- reqctl untag {identity}, then reqctl tag lines "
184
+ "that do"))
185
+ return problems
186
+
187
+
188
+ # @req+ REQ-20885376@eU3vxq0uOnB- t3wbhh
189
+ # @req+ REQ-69161009@gmE3x-nnaEYM awdr2c
190
+ SQL_OPENS = re.compile(r"--|/\*|(?<![\w$])[Ee]'|'|\"|(?<![\w$])\$(?:[^\W\d]\w*)?\$")
191
+
192
+
193
+ def _closed(text, at, quote, escaping):
194
+ while at < len(text):
195
+ if escaping and text[at] == "\\":
196
+ at += 2
197
+ elif text.startswith(quote * 2, at):
198
+ at += 2
199
+ elif text[at] == quote:
200
+ return at + 1
201
+ else:
202
+ at += 1
203
+ return None
204
+
205
+
206
+ def _nested(text, at):
207
+ depth = 1
208
+ while depth and at < len(text):
209
+ step = text[at:at + 2]
210
+ depth += (step == "/*") - (step == "*/")
211
+ at += 2 if step in ("/*", "*/") else 1
212
+ return None if depth else at
213
+
214
+
215
+ def _sql(text):
216
+ spans, at, unclosed = [], 0, None
217
+ while (found := SQL_OPENS.search(text, at)) is not None:
218
+ opener = found[0]
219
+ if opener == "--":
220
+ end = text.find("\n", found.start())
221
+ end = len(text) if end < 0 else end
222
+ elif opener == "/*":
223
+ end = _nested(text, found.end())
224
+ elif opener.startswith("$"):
225
+ close = text.find(opener, found.end())
226
+ end = None if close < 0 else close + len(opener)
227
+ else:
228
+ end = _closed(text, found.end(), opener[-1], opener[0] in "Ee")
229
+ if end is None:
230
+ unclosed, end = text.count("\n", 0, found.start()) + 1, len(text)
231
+ spans.append((found.start(), end, opener not in ("--", "/*")))
232
+ at = end
233
+ return spans, unclosed
234
+ # @req- awdr2c
235
+ # @req- t3wbhh
236
+
237
+
238
+ def _spans(suffix, text):
239
+ if suffix == ".sql":
240
+ return _sql(text)[0]
241
+ if suffix in typescript.SUFFIXES:
242
+ return typescript.read(suffix, text).spans
243
+ return [(found.start(), found.end(), found.groupdict().get("kept") is not None)
244
+ for found in REMARKS[suffix].finditer(text)]
245
+
246
+
247
+ def _remarked(suffix, text, spoken):
248
+ held, at = [], 0
249
+ for start, end, kept in _spans(suffix, text):
250
+ held += [text[at:start], spoken(text[start:end], kept)]
251
+ at = end
252
+ return "".join(held) + text[at:]
253
+
254
+
255
+ def remarks(suffix, text):
256
+ return remarked(text, _spans(suffix, text))
257
+
258
+
259
+ # @req+ REQ-82335572@EEu579sUxCO2 jflstj
260
+ # @req+ REQ-79989567@oaSFUA9o3D48 6fth2x
261
+ REMARKS = {**dict.fromkeys((".py", ".yml", ".yaml"), re.compile(r"#[^\n]*")),
262
+ **dict.fromkeys((".md", ".html"), re.compile(r"<!--[\s\S]*?(?:-->|\Z)")),
263
+ ".js": re.compile(r"""(?P<kept>"(?:\\[\s\S]|[^"\\\n])*"|'(?:\\[\s\S]|[^'\\\n])*'"""
264
+ r"""|`(?:\\[\s\S]|[^`\\])*`)|//[^\n]*|/\*[\s\S]*?(?:\*/|\Z)""")}
265
+
266
+
267
+ def hollow(path, text, first, last):
268
+ suffix, text = _view(Path(path).suffix, text, first)
269
+ bare = _remarked(suffix, text, lambda said, kept: said if kept else "\n" * said.count("\n"))
270
+ return not "".join(_feed_lines(bare)[first - 1:last]).strip()
271
+ # @req- 6fth2x
272
+ # @req- jflstj
273
+
274
+
275
+ RAW = re.compile(REMARKS[".html"].pattern
276
+ + r"|<style(?=[\s/>])[^>]*>(?P<style>[\s\S]*?)(?:</style(?=[\s/>])|\Z)"
277
+ r"|<script(?=[\s/>])[^>]*>(?P<script>[\s\S]*?)(?:</script(?=[\s/>])|\Z)",
278
+ re.IGNORECASE)
279
+
280
+
281
+ def _row(text, offset):
282
+ return text.count("\n", 0, offset) + 1
283
+
284
+
285
+ def _between(text, start, end, kind):
286
+ return dict.fromkeys(range(_row(text, start) + 1, _row(text, end)), kind)
287
+
288
+
289
+ def _literals(text, start, end):
290
+ return {number: kind for found in REMARKS[".js"].finditer(text, start, end)
291
+ if (found["kept"] or "").startswith("`")
292
+ for number, kind in _between(text, found.start(), found.end() - 1,
293
+ "template literal").items()}
294
+
295
+
296
+ def _marked(text):
297
+ return {number for number, line in enumerate(_feed_lines(text), start=1)
298
+ if SIGN.search(line)}
299
+
300
+
301
+ @functools.cache
302
+ def _embedded(suffix, text):
303
+ if suffix == ".js":
304
+ return _literals(text, 0, len(text))
305
+ if suffix in typescript.SUFFIXES:
306
+ return typescript.embedded(suffix, text, _marked(text))
307
+ if suffix != ".html":
308
+ return {}
309
+ held = {}
310
+ for found in RAW.finditer(text):
311
+ if found["style"] is not None:
312
+ held |= _between(text, found.start("style"), found.end("style"), "style element")
313
+ elif found["script"] is not None:
314
+ held |= _literals(text, found.start("script"), found.end("script"))
315
+ return held
316
+
317
+
318
+ # @req+ REQ-12235023@uEMBe2x_2uwp njbuvc
319
+ @functools.cache
320
+ def _scalars(text):
321
+ return {number for event in yaml.parse(text, Loader=corpus.Loader)
322
+ if isinstance(event, yaml.ScalarEvent)
323
+ for number in range(event.start_mark.line + 1,
324
+ event.end_mark.line + 1 + bool(event.end_mark.column))}
325
+
326
+
327
+ def _alone(path, text):
328
+ suffix = Path(path).suffix
329
+ # @req+ REQ-35587066@UWrbgGEbefJg lds6u4
330
+ if suffix != ".md":
331
+ return _lone(suffix, text)
332
+ close = _front(text)
333
+ return ({number for number in _lone(suffix, text) if not 1 < number < close}
334
+ | _lone(".yml", _matter(text)))
335
+ # @req- lds6u4
336
+
337
+
338
+ def _lone(suffix, text):
339
+ try:
340
+ spoken = (_depths(text)[0] if suffix == ".py"
341
+ else _scalars(text) if suffix in (".yml", ".yaml") else {})
342
+ except (tokenize.TokenError, IndentationError, SyntaxError, yaml.YAMLError):
343
+ spoken = {}
344
+ bare = _remarked(suffix, text, lambda said, kept: said if "\n" in said or kept else "\0")
345
+ return {number for number, line in enumerate(_feed_lines(bare), start=1)
346
+ if number not in spoken and line.strip() == "\0"}
347
+ # @req- njbuvc
348
+
349
+
350
+ def read(root, digested=False, uid=None):
351
+ sources = list(_sources(root))
352
+ citations, faults = _parse(sources)
353
+ # @req+ REQ-64570886@Zckz3LzHUzXn g2iusc
354
+ owners = {parsed["id"]: parsed["uid"].partition(".")[0]
355
+ for relative, text in sources if uid and text is not None
356
+ for _, _, parsed in markers(text, Path(relative).suffix)
357
+ if parsed and parsed["id"] and parsed["uid"]}
358
+
359
+ def mine(owner):
360
+ return not uid or owner is None or owner == uid
361
+ problems = [fault for about, fault in faults if mine(owners.get(about))]
362
+ # @req- g2iusc
363
+ # @req> REQ-32191310@aZkvQ6hvIwPv yjp5s6
364
+ if digested:
365
+ texts = dict(sources)
366
+ # @req> REQ-59827868@eeHZvhZPFhD3 tsczoy
367
+ reach = Reach(texts)
368
+ for citation in citations:
369
+ citation["digest"] = digest(citation["path"], texts[citation["path"]],
370
+ citation["lines"], reach)
371
+ # @req+ REQ-79989567@oaSFUA9o3D48 bz6frh
372
+ texts = dict(sources)
373
+ # @req> REQ-64570886@Zckz3LzHUzXn o4sv56
374
+ # @req> REQ-30898926@Vu28eWaG_1mE ar62n7
375
+ problems += [f"{c['path']}: citation {c['id']} covers no code statement -- "
376
+ f"reqctl untag {c['id']}, then reqctl tag a code statement"
377
+ for c in citations if len(c["marks"]) == 2 and mine(c["uid"])
378
+ and hollow(c["path"], texts[c["path"]], c["first"], c["last"])]
379
+ # @req- bz6frh
380
+ # @req> REQ-64570886@Zckz3LzHUzXn zeqhfc
381
+ return citations, problems + [fault for owner, fault in former(sources)
382
+ if mine(owner)]
383
+
384
+
385
+ def former(sources):
386
+ # @req> REQ-44164687@2UxalfYloyMd q2mqfm
387
+ for relative, text in sources:
388
+ for number, line in enumerate(_feed_lines(text or ""), start=1):
389
+ for uid in FORMER.findall(line):
390
+ # @req> REQ-30898926@Vu28eWaG_1mE 2fo354
391
+ yield uid, (f"{relative}:{number}: names {uid} in a single-line "
392
+ "tag -- remove that comment, then cite the code with "
393
+ "reqctl tag")
394
+
395
+
396
+ def cited(citations):
397
+ held = {}
398
+ for citation in citations:
399
+ held.setdefault(citation["uid"], []).append(
400
+ (citation["path"], citation["pinned"], citation["entry"]))
401
+ return held
402
+
403
+
404
+ def parse(sources):
405
+ citations, faults = _parse(sources)
406
+ return citations, [fault for _, fault in faults]
407
+
408
+
409
+ def _parse(sources, placed=True):
410
+ opened, faults, seen = [], [], {}
411
+ for relative, text in sources:
412
+ # @req> REQ-35805881@37XtC4gyD98k 5h3hpr
413
+ if text is None:
414
+ # @req> REQ-30898926@Vu28eWaG_1mE nltcdy
415
+ faults.append((EVERY, f"{relative}: unreadable while scanning for "
416
+ "statement citations -- make it readable (fix "
417
+ "its permissions), then retry"))
418
+ continue
419
+ # @req> REQ-25428163@jtztw34heeVp ap76fb
420
+ if TIGHT.search(text):
421
+ faults += [(None, f"{relative}:{number}: a citation marker has no "
422
+ "space after its sign; put one between the sign "
423
+ "and what follows")
424
+ for number, line in enumerate(_feed_lines(text), start=1)
425
+ if TIGHT.search(line)]
426
+ found = list(markers(text, Path(relative).suffix))
427
+ if not found:
428
+ continue
429
+ if Path(relative).suffix not in COMMENTS:
430
+ # @req> REQ-30898926@Vu28eWaG_1mE 3lg537
431
+ faults.append((None,
432
+ f"{relative}: carries a statement citation, but citations "
433
+ f"are read only in {', '.join(sorted(COMMENTS))} files -- "
434
+ "reqctl untag each citation id it carries, or remove the "
435
+ "comment by hand where it names none"))
436
+ continue
437
+ # @req> REQ-12235023@uEMBe2x_2uwp 4s3qrw
438
+ alone = _alone(relative, text)
439
+ # @req> REQ-78286717@Lol93VZlA61p ltsm7v
440
+ # @req> REQ-29782682@vNwe2DOIu-N0 zuqhai
441
+ embedded = _embedded(Path(relative).suffix, text)
442
+ live, spans, singles = {}, [], []
443
+ # @req+ REQ-73349683@BNT8xnMPL2bn gqpqj6
444
+ for number, body, parsed in found:
445
+ # @req+ REQ-78286717@Lol93VZlA61p eynr4h
446
+ # @req+ REQ-29782682@vNwe2DOIu-N0 67wjjk
447
+ kind = embedded.get(number)
448
+ if kind and placed:
449
+ # @req> REQ-30898926@Vu28eWaG_1mE 2ezs26
450
+ faults.append((parsed and parsed["id"],
451
+ f"{relative}:{number}: holds a citation marker inside a "
452
+ f"{kind}, where it is not read as a comment -- reqctl "
453
+ f"untag its citation, then reqctl tag lines outside the "
454
+ f"{kind}"))
455
+ continue
456
+ # @req- 67wjjk
457
+ # @req- eynr4h
458
+ # @req> REQ-12235023@uEMBe2x_2uwp sbc4do
459
+ if not kind and number not in alone:
460
+ # @req> REQ-30898926@Vu28eWaG_1mE nmx3xd
461
+ faults.append((None, f"{relative}:{number}: holds a citation marker "
462
+ "and more than white space and one single-line "
463
+ "comment, so no citation is read from it -- "
464
+ "reqctl untag its citation, then reqctl tag "
465
+ "again"))
466
+ continue
467
+ # @req> REQ-11268295@s4gX4poitxmM q7622j
468
+ if parsed is None or not parsed["id"]:
469
+ # @req> REQ-30898926@Vu28eWaG_1mE a7oxjp
470
+ faults.append((None, f"{relative}:{number}: `{body}` names no "
471
+ "citation identity -- remove the comment by "
472
+ "hand, or replace it by citing the code with "
473
+ "reqctl tag"))
474
+ continue
475
+ identity = parsed["id"]
476
+ if parsed["sign"] in (OPEN, SINGLE):
477
+ if not parsed["uid"]:
478
+ # @req> REQ-30898926@Vu28eWaG_1mE aogklo
479
+ faults.append((identity, f"{relative}:{number}: citation "
480
+ f"{identity} names no requirement -- "
481
+ f"reqctl untag {identity}, then reqctl "
482
+ "tag again"))
483
+ continue
484
+ # @req> REQ-44424329@FMmHjrkfDEYR det3om
485
+ if identity in seen:
486
+ # @req> REQ-30898926@Vu28eWaG_1mE k2nkiy
487
+ faults.append((identity,
488
+ f"{relative}:{number}: citation {identity} is also "
489
+ f"opened at {seen[identity]}, though an identity "
490
+ "names one citation -- reqctl untag one of the "
491
+ "two, then reqctl tag again"))
492
+ seen.setdefault(identity, f"{relative}:{number}")
493
+ if parsed["sign"] == SINGLE:
494
+ singles.append((number, parsed))
495
+ continue
496
+ live[identity] = (number, parsed)
497
+ elif identity not in live:
498
+ # @req> REQ-30898926@Vu28eWaG_1mE mqimpm
499
+ faults.append((identity, f"{relative}:{number}: closes citation "
500
+ f"{identity}, which is not open here -- "
501
+ f"reqctl untag {identity}"))
502
+ else:
503
+ # @req+ REQ-52925332@8dtaEN9KH9jO wbbbtu
504
+ start, held = live.pop(identity)
505
+ spans.append((identity, start + 1, number - 1))
506
+ opened.append(_citation(held, relative, start, number,
507
+ start + 1, number - 1, [start, number]))
508
+ # @req- wbbbtu
509
+ for identity, (number, _) in live.items():
510
+ # @req> REQ-30898926@Vu28eWaG_1mE gf5tft
511
+ faults.append((identity, f"{relative}:{number}: opens citation "
512
+ f"{identity} and never closes it -- reqctl "
513
+ f"untag {identity}, then reqctl tag the lines "
514
+ "again"))
515
+ # @req- gqpqj6
516
+ for number, held in singles:
517
+ span = following(relative, text, number)
518
+ # @req> REQ-95865306@6u2XMnjDwlvR ytqmle
519
+ if span is None:
520
+ # @req> REQ-30898926@Vu28eWaG_1mE gt4zxq
521
+ faults.append((held["id"], f"{relative}:{number}: citation "
522
+ f"{held['id']} has no code statement after "
523
+ f"it -- reqctl untag {held['id']}"))
524
+ continue
525
+ opened.append(_citation(held, relative, number, span[1], *span,
526
+ [number]))
527
+ # @req> REQ-51709712@D0x4hZmSHgiW l3wdgx
528
+ faults += cut(relative, text, spans if placed else [])
529
+ return covered(opened), faults
530
+
531
+
532
+ # @req> REQ-22755763@lFaaseccSg81 rafirp
533
+ def _citation(held, relative, start, close, first, last, marks):
534
+ uid, _, entry = held["uid"].partition(".")
535
+ return {"id": held["id"], "uid": uid, "entry": entry or None,
536
+ "address": held["uid"], "pinned": held["stamp"] or "",
537
+ "exclusive": bool(held["exclusive"]), "path": relative,
538
+ "open": start, "close": close, "first": first, "last": last,
539
+ "marks": marks}
540
+
541
+
542
+ def following(path, text, number, viewed=None):
543
+ suffix, text = _view(Path(path).suffix, text, viewed or number)
544
+ read = NESTS.get(suffix)
545
+ if read is not None:
546
+ try:
547
+ depths, starts, _ = read(text)
548
+ except (tokenize.TokenError, IndentationError, SyntaxError, yaml.YAMLError):
549
+ depths = None
550
+ # @req> REQ-42668747@CRvKDa_F_lxE cfabxh
551
+ if depths is not None:
552
+ later = sorted(n for n in starts if n > number)
553
+ if not later:
554
+ return None
555
+ first = later[0]
556
+ level = depths[first]
557
+ beyond = [n for n in later if n > first and depths[n] <= level]
558
+ last = max(n for n in depths
559
+ if first <= n and (not beyond or n < beyond[0]))
560
+ return first, last
561
+ lines = _feed_lines(text)
562
+ # @req> REQ-37846580@oqAWJFdNHvDo lrybex
563
+ for at in range(number, len(lines)):
564
+ if lines[at].strip() and not SIGN.search(lines[at]):
565
+ return at + 1, at + 1
566
+ return None
567
+
568
+
569
+ def covered(citations):
570
+ # @req> REQ-73495055@cPNC8izAHGTl lihjvk
571
+ marks = {(c["path"], line) for c in citations for line in c["marks"]}
572
+ # @req+ REQ-33202540@Y-kVcYdmW6zT r4lu24
573
+ # @req+ REQ-97852648@9PG_RRu-Iv2o cl74rk
574
+ exclusive = {}
575
+ for held in citations:
576
+ if held["exclusive"]:
577
+ exclusive.setdefault(held["path"], []).append(held)
578
+ # @req- cl74rk
579
+ # @req- r4lu24
580
+ for citation in citations:
581
+ lines = []
582
+ for line in range(citation["first"], citation["last"] + 1):
583
+ # @req> REQ-73495055@cPNC8izAHGTl jtgzg5
584
+ if (citation["path"], line) in marks:
585
+ continue
586
+ # @req+ REQ-33202540@Y-kVcYdmW6zT 6o76zm
587
+ # @req+ REQ-97852648@9PG_RRu-Iv2o fto5gh
588
+ claimed = [c for c in exclusive.get(citation["path"], [])
589
+ if c["first"] <= line <= c["last"]]
590
+ # @req> REQ-17608335@ejFjhGoCg5c7 4makw7
591
+ owners = ([max(claimed, key=lambda c: c["open"])] if claimed
592
+ else [citation])
593
+ # @req- fto5gh
594
+ # @req- 6o76zm
595
+ if citation in owners:
596
+ lines.append(line)
597
+ citation["lines"] = lines
598
+ return citations
599
+
600
+
601
+ def _sources(root):
602
+ root = Path(root)
603
+ # @req+ REQ-35805881@37XtC4gyD98k ofycdw
604
+ unread = []
605
+ # @req> REQ-81857516@chYOQDPmuDuW ldlf2u
606
+ for folder, subdirs, files in os.walk(root, onerror=unread.append):
607
+ subdirs[:] = [name for name in subdirs
608
+ if not corpus.skipped(root, Path(folder) / name)]
609
+ for name in files:
610
+ path = Path(folder) / name
611
+ try:
612
+ if not stat.S_ISREG(path.stat().st_mode):
613
+ continue
614
+ raw = path.read_bytes()
615
+ except FileNotFoundError:
616
+ continue
617
+ except OSError:
618
+ yield str(path.relative_to(root)), None
619
+ continue
620
+ if b"\0" in raw:
621
+ continue
622
+ # @req> REQ-36103991@1sBO1pub0_IK xx3hop
623
+ yield str(path.relative_to(root)), raw.decode(errors="ignore").removeprefix(BOM)
624
+ for failure in unread:
625
+ yield str(Path(failure.filename).relative_to(root)), None
626
+ # @req- ofycdw
627
+
628
+
629
+ def mint(held):
630
+ while True:
631
+ # @req+ REQ-92239556@4-oyw0syx-xA uprfcz
632
+ identity = "".join(secrets.choice(ALPHABET) for _ in range(6))
633
+ if identity not in held:
634
+ return identity
635
+ # @req- uprfcz
636
+
637
+
638
+ def _ending(line):
639
+ return line[len(line.rstrip("\r\n")):]
640
+
641
+
642
+ # @req> REQ-89706423@fyVHTsenA51D rkepcq
643
+ def _indent(line):
644
+ body = line.rstrip("\r\n")
645
+ return body[:len(body) - len(body.lstrip())]
646
+
647
+
648
+ # @req> REQ-89706423@fyVHTsenA51D 5nywje
649
+ def _inserted(lines, before, after):
650
+ ending = next((_ending(line) for line in lines if _ending(line)), "\n")
651
+ out = []
652
+ for number, line in enumerate(lines, start=1):
653
+ out += [marker + (_ending(line) or ending)
654
+ for _, _, marker in sorted(before.get(number, []))]
655
+ closings = [marker for _, _, marker in sorted(after.get(number, []))]
656
+ if closings and not _ending(line):
657
+ out += [line + ending, ending.join(closings)]
658
+ continue
659
+ out.append(line)
660
+ out += [marker + (_ending(line) or ending) for marker in closings]
661
+ return "".join(out)
662
+
663
+
664
+ # @req> REQ-73851379@CGbsH1UOrU8C 26lc4r
665
+ def _duplicates(path, found, asked):
666
+ marks = {line for citation in found for line in citation["marks"]}
667
+ spanned = {citation["id"]: tuple(n for n in range(citation["first"],
668
+ citation["last"] + 1)
669
+ if n not in marks)
670
+ for citation in found}
671
+ order = list(asked)
672
+ problems = []
673
+ for identity, (first, last, uid, _) in asked.items():
674
+ for other in found:
675
+ earlier = (other["id"] not in asked
676
+ or order.index(other["id"]) < order.index(identity))
677
+ if (other["address"] == uid and earlier
678
+ and spanned[other["id"]] == spanned[identity]):
679
+ # @req> REQ-30898926@Vu28eWaG_1mE dz352n
680
+ problems.append(
681
+ f"{path}: lines {first}-{last} are already cited for {uid}"
682
+ + (" by this command" if other["id"] in asked
683
+ else f" by citation {other['id']}")
684
+ + " -- reqctl untag the existing citation, or cite other lines")
685
+ break
686
+ return problems
687
+
688
+
689
+ # @req> REQ-60346603@FI4Rjvk1iDNp e2efde
690
+ def tagged(root, path, asked, exclusive=False):
691
+ target = Path(path)
692
+ if target.suffix not in COMMENTS:
693
+ # @req> REQ-30898926@Vu28eWaG_1mE z2sgqd
694
+ raise ReqctlError(f"{path}: citations are written only in "
695
+ f"{', '.join(sorted(COMMENTS))} files -- cite a file of "
696
+ "one of those types")
697
+ # @req> REQ-89706423@fyVHTsenA51D thl334
698
+ if not target.resolve().is_relative_to(Path(root).resolve()):
699
+ # @req> REQ-30898926@Vu28eWaG_1mE mh6b74
700
+ raise ReqctlError(f"{path}: not in the code base at {root} -- give a "
701
+ "path inside it")
702
+ # @req+ REQ-71794830@YqhGXWh66eq_ tbkz5r
703
+ base = Path(root).resolve()
704
+ folder = next((up for up in target.resolve().parents
705
+ if up != base and up.is_relative_to(base)
706
+ and corpus.skipped(base, up)), None)
707
+ if folder is not None:
708
+ # @req> REQ-30898926@Vu28eWaG_1mE kn3gln
709
+ raise ReqctlError(f"{path}: {folder.relative_to(base)} is a folder the "
710
+ "citation scan skips, so a citation there is never "
711
+ "read -- cite code outside it")
712
+ # @req- tbkz5r
713
+ # @req+ REQ-36103991@1sBO1pub0_IK 6pharc
714
+ raw = target.read_bytes().decode()
715
+ text = raw.removeprefix(BOM)
716
+ # @req- 6pharc
717
+ lines = _feed_lines(text)
718
+ # @req> REQ-65738797@3jHtqzLVQUal msfg5w
719
+ for first, last, _, _ in asked:
720
+ if not 1 <= first <= last <= len(lines):
721
+ # @req> REQ-30898926@Vu28eWaG_1mE wnhqm4
722
+ raise ReqctlError(f"{path}: lines {first}-{last} are not in the file "
723
+ "-- pass --from and --to within the file")
724
+ # @req+ REQ-55291596@DLuklmxajzSV 7nnmie
725
+ edge = _front(text) if target.suffix == ".md" else 0
726
+ crossing = [f"{path}: lines {first}-{last} cross the edge of the front matter -- "
727
+ "cite lines between its --- lines, or lines after them"
728
+ for first, last, _, _ in asked
729
+ if first <= edge and not 1 < first <= last < edge]
730
+ if crossing:
731
+ # @req> REQ-30898926@Vu28eWaG_1mE xqueam
732
+ raise ReqctlError("\n".join(crossing))
733
+ # @req- 7nnmie
734
+ sources = list(_sources(root))
735
+ held, problems = parse(sources)
736
+ # @req> REQ-35805881@37XtC4gyD98k kz4urn
737
+ if any(text is None for _, text in sources):
738
+ # @req> REQ-30898926@Vu28eWaG_1mE 2ejkra
739
+ raise ReqctlError("the code's citations do not read:\n" + "\n".join(problems))
740
+ taken = {c["id"] for c in held}
741
+ word = " exclusive" if exclusive else ""
742
+ # @req+ REQ-81417723@q6UfXsP6e-N7 kmbhnk
743
+ # @req+ REQ-62782894@x4o_oB5H0-tY k2lzwu
744
+ before, after, minted = {}, {}, []
745
+ for at, (first, last, uid, stamp) in enumerate(asked):
746
+ identity = mint(taken)
747
+ taken.add(identity)
748
+ minted.append(identity)
749
+ indent = _indent(lines[first - 1])
750
+ # @req> REQ-55291596@DLuklmxajzSV 7zqv4i
751
+ lead, tail = COMMENTS[_view(target.suffix, text, first)[0]]
752
+ # @req+ REQ-61906662@vIt4Qdb6Q01L 4z5goa
753
+ single = _statement(path, text, first, last)
754
+ sign = SINGLE if single else OPEN
755
+ # @req+ REQ-84469558@zZGF1hH7cNnc hp5jwl
756
+ before.setdefault(first, []).append(
757
+ (-last, at, f"{indent}{lead}@req{sign} {uid}@{stamp} {identity}{word}{tail}"))
758
+ if not single:
759
+ after.setdefault(last, []).append(
760
+ (-first, -at, f"{indent}{lead}@req{CLOSE} {identity}{tail}"))
761
+ # @req- hp5jwl
762
+ # @req- 4z5goa
763
+ written = _inserted(lines, before, after)
764
+ # @req- k2lzwu
765
+ # @req- kmbhnk
766
+ # @req+ REQ-69161009@gmE3x-nnaEYM n5z4vo
767
+ # @req+ REQ-20885376@eU3vxq0uOnB- 4wwahn
768
+ alone = _alone(path, written)
769
+ # @req+ REQ-39743451@3H-M6CvTxyI0 xasn2j
770
+ # @req+ REQ-62110789@XlGrbQnAd80g hczsn5
771
+ embedded = _embedded(target.suffix, written)
772
+ falling = {parsed["id"]: embedded.get(number, "multiline comment or string")
773
+ for number, _, parsed in markers(written, target.suffix)
774
+ if parsed and (number in embedded or number not in alone)}
775
+ # @req> REQ-30898926@Vu28eWaG_1mE 6tukfm
776
+ inside = [f"{path}: lines {first}-{last}: a comment carrying the citation would "
777
+ f"fall inside a {falling[identity]} -- choose lines outside the "
778
+ f"{falling[identity]}"
779
+ for identity, (first, last, _, _) in zip(minted, asked)
780
+ if identity in falling]
781
+ if inside:
782
+ # @req> REQ-30898926@Vu28eWaG_1mE dlokdg
783
+ raise ReqctlError("\n".join(inside))
784
+ # @req- hczsn5
785
+ # @req- xasn2j
786
+ # @req- 4wwahn
787
+ # @req- n5z4vo
788
+ found, dropped = parse([(path, written)])
789
+ new = [c for identity in minted for c in found if c["id"] == identity]
790
+ # @req> REQ-82335572@EEu579sUxCO2 stzuaz
791
+ if len(new) < len(minted):
792
+ # @req> REQ-30898926@Vu28eWaG_1mE dejtqm
793
+ raise ReqctlError("\n".join(dropped))
794
+ # @req+ REQ-65738797@3jHtqzLVQUal zkxsxx
795
+ problems = [problem for _, problem in cut(
796
+ path, written, [(c["id"], c["first"], c["last"])
797
+ for c in new if len(c["marks"]) == 2])]
798
+ # @req> REQ-82335572@EEu579sUxCO2 5lq35y
799
+ # @req> REQ-30898926@Vu28eWaG_1mE 3pdc4x
800
+ problems += [f"{path}: lines {first}-{last} hold no code statement -- "
801
+ "choose --from and --to lines that hold one"
802
+ for first, last, _, _ in asked if hollow(path, text, first, last)]
803
+ problems += _duplicates(path, found, dict(zip(minted, asked)))
804
+ if problems:
805
+ # @req> REQ-30898926@Vu28eWaG_1mE c7h3c7
806
+ raise ReqctlError("\n".join(problems))
807
+ # @req- zkxsxx
808
+ # @req+ REQ-59827868@eeHZvhZPFhD3 entyuu
809
+ relative = target.resolve().relative_to(base).as_posix()
810
+ reach = Reach({**dict(sources), relative: written})
811
+ # @req- entyuu
812
+ # @req> REQ-36103991@1sBO1pub0_IK xfy5v4
813
+ return ((target, (raw[:len(raw) - len(text)] + written).encode()),
814
+ [(c["id"], c["uid"], digest(relative, written, c["lines"], reach))
815
+ for c in new])
816
+
817
+
818
+ # @req> REQ-96580262@hSRVhY3ybYl9 nevicm
819
+ def pinning(root, identities):
820
+ sources = dict(_sources(root))
821
+ found = {citation["id"]: citation for citation in _read(sources.items(), identities, True)}
822
+ missing = [identity for identity in identities if identity not in found]
823
+ # @req> REQ-41024637@JaitgDKH7S2S 2usjww
824
+ # @req> REQ-30898926@Vu28eWaG_1mE 3vmsym
825
+ if missing:
826
+ raise ReqctlError(f"no statement citation names {', '.join(missing)} -- reqctl "
827
+ "trace UID lists the identities an item is cited by; nothing "
828
+ "was re-pinned")
829
+ # @req+ REQ-64846889@TSG5uy3nM0nk wbaon5
830
+ # @req+ REQ-59827868@eeHZvhZPFhD3 unauut
831
+ reach = Reach(sources)
832
+ return [(citation, digest(citation["path"], sources[citation["path"]], citation["lines"], reach))
833
+ for citation in (found[identity] for identity in identities)]
834
+ # @req- unauut
835
+ # @req- wbaon5
836
+
837
+
838
+ # @req+ REQ-62685242@nMlCr6nshoNZ yqdz42
839
+ def _read(sources, identities, placed):
840
+ held, faults = _parse(sources, placed)
841
+ found = [citation for citation in held
842
+ if identities is EVERY or citation["id"] in identities]
843
+ named = () if identities is EVERY else (*identities, *(c["path"] for c in found))
844
+ against = [fault for about, fault in faults if about is EVERY or about in named]
845
+ # @req> REQ-30898926@Vu28eWaG_1mE z3j7ql
846
+ if against:
847
+ raise ReqctlError("the code's citations do not read:\n" + "\n".join(against))
848
+ return found
849
+
850
+
851
+ def readable(root, identity=EVERY, placed=True):
852
+ return _read(_sources(root), identity if identity is EVERY else (identity,), placed)
853
+
854
+
855
+ def named(root, identity, placed=True):
856
+ for citation in readable(root, identity, placed):
857
+ return citation
858
+ # @req> REQ-51778557@2aIQFI4KxvJ3 2j6t3u
859
+ # @req> REQ-41024637@JaitgDKH7S2S cdi6vq
860
+ # @req> REQ-30898926@Vu28eWaG_1mE bw5uk2
861
+ raise ReqctlError(f"no statement citation names {identity} -- reqctl "
862
+ "trace UID lists the identities an item is cited by")
863
+ # @req- yqdz42
864
+
865
+
866
+ def _decoded(target, path):
867
+ try:
868
+ return target.read_bytes().decode()
869
+ except UnicodeDecodeError as error:
870
+ # @req> REQ-30898926@Vu28eWaG_1mE jfdla5
871
+ raise ReqctlError(f"cannot read {path}: not valid UTF-8 -- save the "
872
+ "file as UTF-8") from error
873
+
874
+
875
+ def _lines(root, citation):
876
+ target = Path(root) / citation["path"]
877
+ # @req+ REQ-36103991@1sBO1pub0_IK zibglc
878
+ text = _decoded(target, citation["path"])
879
+ held = text.removeprefix(BOM)
880
+ return target, text[:len(text) - len(held)], _feed_lines(held)
881
+ # @req- zibglc
882
+
883
+
884
+ def repinned(root, pins):
885
+ # @req+ REQ-17757558@XeiCnIpn4RLV cnpmsp
886
+ target, mark, lines = _lines(root, pins[0][0])
887
+ for citation, stamp in pins:
888
+ at = citation["open"] - 1
889
+ pinned = re.compile(rf"(@req[{OPEN}{SINGLE}]\s+{re.escape(citation['address'])})(?:@\S*)?")
890
+ # @req> REQ-18833394@iNDqEsjKcVn- gvfbsp
891
+ lines[at] = pinned.sub(rf"\g<1>@{stamp}", lines[at], count=1)
892
+ # @req> REQ-36103991@1sBO1pub0_IK wu6oxx
893
+ return target, (mark + "".join(lines)).encode()
894
+ # @req- cnpmsp
895
+
896
+
897
+ def every(root):
898
+ held, faults = parse(_sources(root))
899
+ if faults:
900
+ # @req> REQ-30898926@Vu28eWaG_1mE 3qmuhz
901
+ raise ReqctlError("the code's citations do not read:\n" + "\n".join(faults))
902
+ return held
903
+
904
+
905
+ def renamed(root, citation, name):
906
+ # @req+ REQ-70208796@rx8OkxlDOpk4 4otwfp
907
+ target, mark, lines = _lines(root, citation)
908
+ at = citation["open"] - 1
909
+ address = f"{name}.{citation['entry']}" if citation["entry"] else name
910
+ pinned = re.compile(rf"(@req[{OPEN}{SINGLE}]\s+){re.escape(citation['address'])}(?=@)")
911
+ lines[at], done = pinned.subn(lambda found: f"{found.group(1)}{address}",
912
+ lines[at], count=1)
913
+ if not done:
914
+ # @req> REQ-30898926@Vu28eWaG_1mE 5hzcy3
915
+ raise ReqctlError(f"{citation['path']}:{citation['open']}: citation "
916
+ f"{citation['id']} does not read as naming "
917
+ f"{citation['address']}; nothing was renamed -- "
918
+ f"reqctl untag {citation['id']}, then reqctl tag "
919
+ "again")
920
+ return target, (mark + "".join(lines)).encode()
921
+ # @req- 4otwfp
922
+
923
+
924
+ def untagged(root, citation):
925
+ target, mark, lines = _lines(root, citation)
926
+ marks = set(citation["marks"])
927
+ # @req> REQ-81275367@2q0tIHQCM4br 5gkn5k
928
+ # @req> REQ-26984738@2Cu-ngxaDd6z a4ywox
929
+ # @req> REQ-36103991@1sBO1pub0_IK vlp2ka
930
+ return target, (mark + "".join(line for number, line in enumerate(lines, start=1)
931
+ if number not in marks)).encode()
932
+
933
+
934
+ def _statement(path, text, first, last):
935
+ lines = _feed_lines(text)
936
+ code = [n for n in range(first, last + 1) if lines[n - 1].strip()]
937
+ if not code or code[0] != first:
938
+ return False
939
+ return following(path, text, first - 1, first) == (first, code[-1])
940
+
941
+
942
+ # @req> REQ-31471659@hKmNhTkU9xOA k3upi2
943
+ UNREAD = (tokenize.NL, tokenize.ENDMARKER, tokenize.INDENT, tokenize.DEDENT)
944
+
945
+
946
+ @functools.cache
947
+ def _stream(text):
948
+ held, depth = [], 0
949
+ for token in tokenize.generate_tokens(io.StringIO(text).readline):
950
+ if token.type == tokenize.INDENT:
951
+ depth += 1
952
+ elif token.type == tokenize.DEDENT:
953
+ depth -= 1
954
+ held.append((token.start[0], token.type, depth, token.string))
955
+ return held
956
+
957
+
958
+ # @req> REQ-31471659@hKmNhTkU9xOA 4hf3xj
959
+ def _tokens(text, wanted):
960
+ held, first = [], None
961
+ for line, kind, depth, string in _stream(text):
962
+ if line not in wanted or kind in UNREAD:
963
+ continue
964
+ first = depth if first is None else first
965
+ held.append([tokenize.tok_name[kind], depth - first,
966
+ "" if kind == tokenize.NEWLINE else string])
967
+ return held
968
+
969
+
970
+ def digest(path, text, lines, reach=None):
971
+ wanted = set(lines)
972
+ held = None
973
+ if Path(path).suffix == ".py":
974
+ try:
975
+ # @req> REQ-31471659@hKmNhTkU9xOA suqekj
976
+ held = _tokens(text, wanted)
977
+ except (tokenize.TokenError, IndentationError, SyntaxError):
978
+ held = None
979
+ # @req+ REQ-59827868@eeHZvhZPFhD3 kwj4mx
980
+ taken = reach.taken(path, wanted) if held is not None and reach else []
981
+ if taken:
982
+ held = [held, taken]
983
+ # @req- kwj4mx
984
+ if held is None:
985
+ # @req+ REQ-15770866@PJj7anRT1m47 soehrs
986
+ split = [line.rstrip("\r\n") for line in _feed_lines(text)]
987
+ held = [split[n - 1] for n in sorted(wanted)]
988
+ # @req- soehrs
989
+ # @req+ REQ-22608177@cdmX9Aqh6XXC louba3
990
+ taken = reach.taken(path, wanted) if reach and Path(path).suffix in SCRIPTED else []
991
+ if taken:
992
+ held = [held, taken]
993
+ # @req- louba3
994
+ return corpus.digest(held)
995
+
996
+
997
+ JSON_TOKEN = re.compile(r'(?P<string>"(?:\\.|[^"\\\n])*")'
998
+ r"|(?P<number>-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)"
999
+ r"|(?P<name>true|false|null)|(?P<op>[{}\[\]:,])"
1000
+ r"|(?P<space>[ \t\r\n]+)|(?P<other>.)", re.S)
1001
+
1002
+
1003
+ # @req> REQ-40478019@_dwqsOpSOQXA udeu7w
1004
+ def whole(path, text):
1005
+ if Path(path).suffix == ".py":
1006
+ return digest(path, text, range(1, len(_feed_lines(text)) + 1))
1007
+ held = None
1008
+ if Path(path).suffix == ".json":
1009
+ held = [[found.lastgroup, found.group()] for found in JSON_TOKEN.finditer(text)
1010
+ if found.lastgroup != "space"]
1011
+ if any(kind == "other" for kind, _ in held):
1012
+ held = None
1013
+ return corpus.digest(held if held is not None
1014
+ else [line.rstrip("\r\n") for line in _feed_lines(text)])
1015
+
1016
+
1017
+ def spanned(path, name):
1018
+ # @req> REQ-94119327@SaWRJkHCFkNi gcrrhf
1019
+ if Path(path).suffix != ".py":
1020
+ # @req> REQ-30898926@Vu28eWaG_1mE jmsg5p
1021
+ raise ReqctlError(f"{path}: {name} names a definition only in a .py file -- "
1022
+ "cite lines of it with --from and --to")
1023
+ source = Path(path).read_bytes().decode().removeprefix(BOM).replace("\r\n", "\n").replace("\r", " ")
1024
+ try:
1025
+ tree = ast.parse(source)
1026
+ except (SyntaxError, RecursionError, MemoryError) as error:
1027
+ # @req> REQ-30898926@Vu28eWaG_1mE mgkjni
1028
+ raise ReqctlError(f"{path}: not Python that parses ({error}) -- fix it, or "
1029
+ "cite lines of it with --from and --to") from error
1030
+ # @req+ REQ-74702510@gU2X5OnlUbY3 6r4imu
1031
+ wanted = name.split(".")
1032
+ found, unwalked = [], [(tree, [])]
1033
+ while unwalked:
1034
+ node, chain = unwalked.pop()
1035
+ for child in ast.iter_child_nodes(node):
1036
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1037
+ here = [*chain, child.name]
1038
+ if here[-len(wanted):] == wanted:
1039
+ found.append(child)
1040
+ unwalked.append((child, here))
1041
+ elif isinstance(child, (ast.stmt, ast.excepthandler, ast.match_case)):
1042
+ unwalked.append((child, chain))
1043
+ # @req- 6r4imu
1044
+ # @req> REQ-94119327@SaWRJkHCFkNi yvjo27
1045
+ if not found:
1046
+ # @req> REQ-30898926@Vu28eWaG_1mE 6w3pmt
1047
+ raise ReqctlError(f"{path}: no function, class or method is named {name} -- "
1048
+ "name one the file defines, joining enclosing names with "
1049
+ "dots, or cite lines with --from and --to")
1050
+ # @req> REQ-73249972@0ct6LD_NhZts 6qhks7
1051
+ if len(found) > 1:
1052
+ # @req> REQ-30898926@Vu28eWaG_1mE pxprub
1053
+ raise ReqctlError(f"{path}: {name} names more than one definition, at lines "
1054
+ f"{', '.join(str(line) for line in sorted(node.lineno for node in found))} -- join "
1055
+ "the names enclosing the one meant with dots, or cite "
1056
+ "lines with --from and --to")
1057
+ # @req+ REQ-74702510@gU2X5OnlUbY3 5jbxzz
1058
+ node = found[0]
1059
+ if not node.decorator_list:
1060
+ return node.lineno, node.end_lineno
1061
+ first = node.decorator_list[0]
1062
+ return max(token.start[0] for token in tokenize.generate_tokens(io.StringIO(source).readline)
1063
+ if token.string == "@" and token.start < (first.lineno, first.col_offset)
1064
+ ), node.end_lineno
1065
+ # @req- 5jbxzz
1066
+
1067
+
1068
+ def _rest(text, citation):
1069
+ kept, before = [], 0
1070
+ for number, line in enumerate(_feed_lines(text), start=1):
1071
+ if citation["open"] <= number <= citation["close"] or not line.strip():
1072
+ continue
1073
+ kept.append(line.strip())
1074
+ before += number < citation["open"]
1075
+ return kept, before
1076
+
1077
+
1078
+ def crossed(was, was_text, now, now_text):
1079
+ # @req+ REQ-56906371@wq3BL0IRTLVV vvu3g7
1080
+ if was["path"] != now["path"]:
1081
+ return True
1082
+ old, old_before = _rest(was_text, was)
1083
+ new, new_before = _rest(now_text, now)
1084
+ matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
1085
+ return any((i + k < old_before) != (j + k < new_before)
1086
+ for i, j, size in matcher.get_matching_blocks()
1087
+ for k in range(size))
1088
+ # @req- vvu3g7
1089
+
1090
+
1091
+ def _git(root, *args):
1092
+ found = subprocess.run(["git", *args], cwd=root, capture_output=True,
1093
+ text=True, check=False)
1094
+ if found.returncode:
1095
+ # @req> REQ-30898926@Vu28eWaG_1mE h7pbvo
1096
+ raise ReqctlError(f"git {' '.join(args)}: {found.stderr.strip()} -- "
1097
+ "install git or put it on PATH, run reqctl inside the "
1098
+ "repository, or fetch the ref it names, then retry")
1099
+ return found.stdout
1100
+
1101
+
1102
+ def _carried(root, commit):
1103
+ # @req> REQ-56906371@wq3BL0IRTLVV q45z6w
1104
+ listed = subprocess.run(
1105
+ ["git", "grep", "-l", "-z", "-E", "@req[+>-]([[:space:]]|$)", commit, "--"],
1106
+ cwd=root, capture_output=True, text=True, check=False)
1107
+ if listed.returncode not in (0, 1):
1108
+ # @req> REQ-30898926@Vu28eWaG_1mE eanwpu
1109
+ raise ReqctlError(f"cannot read {commit}: {listed.stderr.strip()} -- "
1110
+ "run `git fetch origin`, then retry")
1111
+ for named in filter(None, listed.stdout.split("\0")):
1112
+ relative = named.split(":", 1)[1]
1113
+ yield relative, _git(root, "show", f"{commit}:{relative}").removeprefix(BOM)
1114
+
1115
+
1116
+ # @req> REQ-59827868@eeHZvhZPFhD3 jwvhp4
1117
+ def _reached_at(root, commit):
1118
+ blobs = [line.split("\t", 1)[1] for line in
1119
+ _git(root, "ls-tree", "-r", "-z", commit).split("\0")
1120
+ if "\t" in line and line.split()[1] == "blob"]
1121
+ base = Path(root)
1122
+ # @req> REQ-22608177@cdmX9Aqh6XXC s4aizn
1123
+ return {path: _git(root, "show", f"{commit}:{path}").removeprefix(BOM)
1124
+ for path in blobs if path.endswith((".py", *SCRIPTED)) and not any(
1125
+ corpus.skipped(base, base / folder) for folder in Path(path).parents[:-1])}
1126
+
1127
+
1128
+ def compare(root, ref):
1129
+ commit = _git(root, "merge-base", "HEAD", ref).strip()
1130
+ was_texts = dict(_carried(root, commit))
1131
+ # @req> REQ-35805881@37XtC4gyD98k wge7e5
1132
+ now_texts = {relative: text for relative, text in _sources(root)
1133
+ if text is None or "@req" in text}
1134
+ was, _ = parse(was_texts.items())
1135
+ now, problems = parse(now_texts.items())
1136
+ if problems:
1137
+ # @req> REQ-30898926@Vu28eWaG_1mE 6swkkb
1138
+ raise ReqctlError("the code's citations do not read:\n"
1139
+ + "\n".join(problems))
1140
+ # @req+ REQ-36428128@pWyff7uDBZIK a5i7lc
1141
+ before = {c["id"]: c for c in was}
1142
+ after = {c["id"]: c for c in now}
1143
+ # @req- a5i7lc
1144
+ # @req> REQ-59827868@eeHZvhZPFhD3 5swpuo
1145
+ was_reach, now_reach = Reach(_reached_at(root, commit)), Reach(dict(_sources(root)))
1146
+ rows = []
1147
+ for identity in sorted(set(before) | set(after)):
1148
+ old, new = before.get(identity), after.get(identity)
1149
+ # @req+ REQ-56906371@wq3BL0IRTLVV bicxbc
1150
+ if old is None or new is None:
1151
+ held = new or old
1152
+ rows.append({"id": identity, "uid": held["uid"],
1153
+ "path": held["path"],
1154
+ "state": ["added"] if old is None else ["deleted"]})
1155
+ continue
1156
+ if (old["path"] == new["path"] and not old["path"].endswith(".py")
1157
+ and was_texts[old["path"]] == now_texts[new["path"]]):
1158
+ continue
1159
+ state = []
1160
+ if crossed(old, was_texts[old["path"]], new, now_texts[new["path"]]):
1161
+ state.append("moved")
1162
+ if (digest(old["path"], was_texts[old["path"]], old["lines"], was_reach)
1163
+ != digest(new["path"], now_texts[new["path"]], new["lines"], now_reach)):
1164
+ state.append("changed")
1165
+ if state:
1166
+ rows.append({"id": identity, "uid": new["uid"], "path": new["path"],
1167
+ "state": state})
1168
+ # @req- bicxbc
1169
+ # @req> REQ-33614084@XHPdW0ynnY4c ily3i7
1170
+ files = _whole_rows(root, commit)
1171
+ # @req> REQ-70581878@Oua6NaKONA27 w6qryd
1172
+ touched = sorted({row["uid"] for row in rows if row["state"] != ["moved"]}
1173
+ | {row["uid"] for row in files})
1174
+ return {"base": commit, "citations": rows, "files": files, "touches": touched}
1175
+
1176
+
1177
+ def _whole_held(root, commit):
1178
+ listed = subprocess.run(
1179
+ ["git", "grep", "-l", "-z", "-F", corpus.WHOLE_FILES, commit, "--",
1180
+ "requirements"],
1181
+ cwd=root, capture_output=True, text=True, check=False)
1182
+ if listed.returncode not in (0, 1):
1183
+ # @req> REQ-30898926@Vu28eWaG_1mE lxsrkt
1184
+ raise ReqctlError(f"cannot read {commit}: {listed.stderr.strip()} -- "
1185
+ "run `git fetch origin`, then retry")
1186
+ for named in filter(None, listed.stdout.split("\0")):
1187
+ relative = named.split(":", 1)[1]
1188
+ data = corpus.loads(_git(root, "show", f"{commit}:{relative}"), relative,
1189
+ aliases=False)
1190
+ if isinstance(data, dict):
1191
+ yield Path(relative).stem, corpus.mapping(data, corpus.WHOLE_FILES)
1192
+
1193
+
1194
+ def _whole_text(root, commit, relative):
1195
+ if not _git(root, "ls-tree", "--name-only", commit, "--", relative).strip():
1196
+ return None
1197
+ return _git(root, "show", f"{commit}:{relative}").removeprefix(BOM)
1198
+
1199
+
1200
+ def _whole_digest(relative, text):
1201
+ return None if text is None else whole(relative, text)
1202
+
1203
+
1204
+ def _whole_rows(root, commit):
1205
+ # @req+ REQ-33614084@XHPdW0ynnY4c 53mrze
1206
+ store, _ = corpus.load(root)
1207
+ renamed_to = {old: item.uid for item in corpus.items(store)
1208
+ for old in corpus.carried_of(item.uid, item.data)}
1209
+ was = {renamed_to.get(uid, uid): held
1210
+ for uid, held in _whole_held(root, commit)}
1211
+ now = {item.uid: held for item in corpus.items(store)
1212
+ if (held := corpus.mapping(item.data, corpus.WHOLE_FILES))}
1213
+ rows = []
1214
+ for uid in sorted(set(was) | set(now)):
1215
+ held, holds = was.get(uid, {}), now.get(uid, {})
1216
+ for relative in sorted(set(held) | set(holds)):
1217
+ if relative not in held or relative not in holds:
1218
+ rows.append({"uid": uid, "path": relative,
1219
+ "state": ["added" if relative in holds else "deleted"]})
1220
+ continue
1221
+ target = Path(root) / relative
1222
+ after = (target.read_bytes().decode().removeprefix(BOM)
1223
+ if target.is_file() else None)
1224
+ if (held[relative] != holds[relative]
1225
+ or _whole_digest(relative, _whole_text(root, commit, relative))
1226
+ != _whole_digest(relative, after)):
1227
+ rows.append({"uid": uid, "path": relative, "state": ["changed"]})
1228
+ return rows
1229
+ # @req- 53mrze