reqctl 0.1.38__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reqctl/__init__.py +0 -0
- reqctl/baseline.py +380 -0
- reqctl/cite.py +1229 -0
- reqctl/cli.py +1514 -0
- reqctl/corpus.py +1283 -0
- reqctl/fields.py +361 -0
- reqctl/graph.py +207 -0
- reqctl/history.py +175 -0
- reqctl/portal.html +2213 -0
- reqctl/portal.py +131 -0
- reqctl/reach.py +425 -0
- reqctl/resolve.py +74 -0
- reqctl/schemas/baseline.schema.yaml +42 -0
- reqctl/schemas/dictionary.schema.yaml +256 -0
- reqctl/schemas/guard.schema.yaml +131 -0
- reqctl/schemas/requirement.schema.yaml +152 -0
- reqctl/settings.py +183 -0
- reqctl/typescript.py +143 -0
- reqctl/uncited.py +257 -0
- reqctl/validate.py +1929 -0
- reqctl/write.py +2004 -0
- reqctl-0.1.38.dist-info/METADATA +11 -0
- reqctl-0.1.38.dist-info/RECORD +26 -0
- reqctl-0.1.38.dist-info/WHEEL +5 -0
- reqctl-0.1.38.dist-info/entry_points.txt +2 -0
- reqctl-0.1.38.dist-info/top_level.txt +1 -0
reqctl/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
|