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/portal.py ADDED
@@ -0,0 +1,131 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import re
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from pathlib import Path
7
+ from urllib.parse import urlsplit
8
+
9
+ from . import fields as _fields
10
+ from . import settings as _settings
11
+ from . import write as _write
12
+ from .baseline import _git
13
+ from .corpus import ReqctlError
14
+
15
+ PAGE = Path(__file__).resolve().parent / "portal.html"
16
+ SCRIPT = re.compile(rb"<script>(.*?)</script>", re.S)
17
+ FIELDS_AT = b'<script type="application/json" id="kind-fields">'
18
+ PERMISSIONS_AT = b'<script type="application/json" id="token-permissions">'
19
+ UNSAFE = {ord(char): f"\\u{ord(char):04x}" for char in "<>&"}
20
+ HOST, PORT = "127.0.0.1", 8374
21
+ GITHUB = re.compile(r"(?:(?:https?|ssh|git)://(?:[^@/]+@)?|[^@/:]+@)"
22
+ r"(?i:github\.com)[:/]([A-Za-z0-9-]+/[A-Za-z0-9._-]+?)"
23
+ r"(?:\.git)?/?")
24
+ USERINFO = re.compile(r"(?<=//)[^@/]+@")
25
+
26
+
27
+ def repository(where):
28
+ # @req+ REQ-95796962@u8QGW_upRZM7 2v7p2v
29
+ if _git(where, "rev-parse", "--git-dir").returncode:
30
+ # @req> REQ-30898926@Vu28eWaG_1mE ikwiin
31
+ raise ReqctlError("not inside a git repository, so no origin remote "
32
+ "names the portal's repository -- run `reqctl "
33
+ "portal` inside the repository")
34
+ url = _git(where, "remote", "get-url", "origin").stdout.strip()
35
+ if not url:
36
+ # @req> REQ-30898926@Vu28eWaG_1mE ajkroh
37
+ raise ReqctlError("no origin remote names the portal's repository -- "
38
+ "add one naming a GitHub owner/repo")
39
+ found = GITHUB.fullmatch(url)
40
+ if found is None:
41
+ # @req> REQ-30898926@Vu28eWaG_1mE cpeug2
42
+ raise ReqctlError(f"origin is {USERINFO.sub('', url)}, not a GitHub "
43
+ "owner/repo -- set it with `git remote set-url "
44
+ "origin https://github.com/OWNER/REPO`")
45
+ # @req- 2v7p2v
46
+ # @req> REQ-53764133@s638VvRMfEgi mbl5cu
47
+ return found.group(1)
48
+
49
+
50
+ # @req+ REQ-87847146@3MkrB9EtSIEF avcfk3
51
+ # @req> REQ-69283350@vYYldulaXQvc cgzp5c
52
+ # @req> REQ-14679866@V0QsDEPmgOq3 357tly
53
+ def _offered(kind, field):
54
+ once = kind == "parameter" and field.name == _fields.ENTRIES
55
+ return {"name": field.name, "flag": field.flag[2:], "required": field.required,
56
+ "repeated": field.repeated and not once, "prose": field.prose,
57
+ "choices": list(field.choices), "parts": list(field.parts),
58
+ "clears": field.clears and field.clears[2:],
59
+ "drops": field.drops and field.drops[2:],
60
+ "form": _write.FORMS.get(field.name)}
61
+
62
+
63
+ # @req> REQ-21522236@jupFEITq3Nms vldi2d
64
+ def forms(root):
65
+ offered = {}
66
+ for kind in _write.KINDS:
67
+ own = _fields.of(root, kind)
68
+ offered[kind] = {"new": [_offered(kind, field) for field in own],
69
+ "revise": [_offered(kind, field) for field
70
+ in _write.revisable_fields(own, kind)
71
+ if not field.address]}
72
+ return offered
73
+
74
+
75
+ def with_forms(page, root):
76
+ before, after = page.split(FIELDS_AT)
77
+ return before + FIELDS_AT + json.dumps(forms(root)).translate(UNSAFE).encode() + after
78
+ # @req- avcfk3
79
+
80
+
81
+ # @req> REQ-40447106@_7d9O2HDnoPr uwkxa6
82
+ # @req> REQ-84459416@ZlrZpYNBUh8Q ufhxxr
83
+ def with_permissions(page, root):
84
+ _, stated = _settings.read(root)
85
+ before, after = page.split(PERMISSIONS_AT)
86
+ return (before + PERMISSIONS_AT + json.dumps(_settings.table(
87
+ stated, "portal_token_permissions")).translate(UNSAFE).encode() + after)
88
+
89
+
90
+ # @req> REQ-56725181@knOj5NP_RuL_ 52i7vh
91
+ def policy_of(page):
92
+ script = SCRIPT.search(page).group(1).replace(b"\r\n", b"\n").replace(b"\r", b"\n")
93
+ held = base64.b64encode(hashlib.sha256(script).digest())
94
+ return (f"default-src 'none'; script-src 'sha256-{held.decode()}'; "
95
+ "style-src 'unsafe-inline'; connect-src https://api.github.com; "
96
+ "form-action 'none'")
97
+
98
+
99
+ # @req> REQ-49576265@pFihs7WdIwGS 6hnjpw
100
+ class Page(BaseHTTPRequestHandler):
101
+ body = b""
102
+ policy = ""
103
+
104
+ def do_GET(self):
105
+ if urlsplit(self.path).path != "/":
106
+ self.send_error(404)
107
+ return
108
+ self.send_response(200)
109
+ self.send_header("Content-Type", "text/html; charset=utf-8")
110
+ # @req> REQ-56725181@knOj5NP_RuL_ fml6m3
111
+ self.send_header("Content-Security-Policy", self.policy)
112
+ self.send_header("Content-Length", str(len(self.body)))
113
+ self.end_headers()
114
+ self.wfile.write(self.body)
115
+
116
+ def log_message(self, *_):
117
+ pass
118
+
119
+
120
+ # @req> REQ-49576265@pFihs7WdIwGS uor45m
121
+ def server():
122
+ root = _fields.root()
123
+ Page.body = with_permissions(with_forms(PAGE.read_bytes(), root), root)
124
+ Page.policy = policy_of(Page.body)
125
+ try:
126
+ return ThreadingHTTPServer((HOST, PORT), Page)
127
+ except OSError as busy:
128
+ # @req> REQ-30898926@Vu28eWaG_1mE g5ihnj
129
+ raise ReqctlError(f"cannot serve the portal at {HOST}:{PORT}: "
130
+ f"{busy.strerror} -- stop whatever holds the port, "
131
+ "then retry") from busy
reqctl/reach.py ADDED
@@ -0,0 +1,425 @@
1
+ import ast
2
+ import io
3
+ import keyword
4
+ import posixpath
5
+ import re
6
+ import tokenize
7
+
8
+ from . import corpus, typescript
9
+
10
+ SCRIPTED = (".js", ".html", *typescript.SUFFIXES)
11
+ CLASSIC = re.compile(r"<script(?=[\s/>])(?P<attrs>[^>]*)>(?P<body>[\s\S]*?)"
12
+ r"(?:</script(?=[\s/>])|\Z)", re.IGNORECASE)
13
+ TYPED = re.compile(r"""\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.IGNORECASE)
14
+ JAVASCRIPT = {"", "text/javascript", "application/javascript", "text/ecmascript",
15
+ "application/ecmascript"}
16
+ LEXED = re.compile(r""""(?:\\[\s\S]|[^"\\\n])*"|'(?:\\[\s\S]|[^'\\\n])*'"""
17
+ r"|(?P<tick>`)|(?P<brace>[{}])"
18
+ r"|(?P<remark>//[^\n]*|/\*[\s\S]*?(?:\*/|\Z)|<!--[^\n]*|^[ \t]*-->[^\n]*)"
19
+ r"|(?P<pattern>/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n\[])+/[A-Za-z]*)",
20
+ re.MULTILINE)
21
+ PATTERN_AFTER = set("(,=:[!&|?{};+-*%<>~^")
22
+ PATTERN_WORDS = {"return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void",
23
+ "throw", "yield", "await", "instanceof"}
24
+ PIECE = re.compile(r"[A-Za-z_$][\w$]*|\d[\w.]*|\.\.\.|[-+*/%&|^!<>=?:~]+|[{}()\[\];,.]")
25
+ IDENTIFIER = re.compile(r"[A-Za-z_$][\w$]*\Z")
26
+ CONTINUING = re.compile(r"(?!\+\+\Z|--\Z)[-+*/%&|^!<>=?:~]+\Z|[(\[,.]\Z|\.\.\.\Z")
27
+ OPENS, CLOSES = "{([", "})]"
28
+ BINDINGS = ("const", "let", "var")
29
+
30
+ # @req+ REQ-59827868@eeHZvhZPFhD3 4pmkcf
31
+ DEFINED = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
32
+ ASSIGNED = (ast.Assign, ast.AnnAssign)
33
+ NESTED = (ast.If, ast.Try, ast.TryStar)
34
+ UNREAD = (tokenize.COMMENT, tokenize.NL, tokenize.ENDMARKER, tokenize.INDENT,
35
+ tokenize.DEDENT)
36
+
37
+
38
+ def _statements(body):
39
+ for node in body:
40
+ yield node
41
+ if isinstance(node, NESTED):
42
+ for branch in ("body", "orelse", "finalbody"):
43
+ yield from _statements(getattr(node, branch, []))
44
+ for handler in getattr(node, "handlers", []):
45
+ yield from _statements(handler.body)
46
+
47
+
48
+ def _bound(target):
49
+ if isinstance(target, ast.Name):
50
+ yield target.id
51
+ elif isinstance(target, (ast.Tuple, ast.List)):
52
+ for element in target.elts:
53
+ yield from _bound(element)
54
+
55
+
56
+ def _span(node):
57
+ first = min([node.lineno, *(d.lineno for d in getattr(node, "decorator_list", []))])
58
+ return first, node.end_lineno or node.lineno
59
+
60
+
61
+ class Reach:
62
+ def __init__(self, texts):
63
+ # @req> REQ-22608177@cdmX9Aqh6XXC 2ap6ns
64
+ self.texts = {path.replace("\\", "/"): text for path, text in texts.items()
65
+ if text is not None and path.endswith((".py", *SCRIPTED))}
66
+ packages = {posixpath.dirname(path) for path in self.texts
67
+ if posixpath.basename(path) == "__init__.py"}
68
+ self.roots = [".", *sorted({posixpath.dirname(folder) or "." for folder in packages
69
+ if posixpath.dirname(folder) not in packages})]
70
+ self.tables, self.streams, self.edges, self.digests = {}, {}, {}, {}
71
+ self.scripts = {}
72
+
73
+ # @req> REQ-22608177@cdmX9Aqh6XXC upntcv
74
+ # @req> REQ-45330509@y34lW04HzVzy ad2sq3
75
+ # @req> REQ-71419978@R9LaJT-ldfB9 eci2zb
76
+ def _scripted(self, path):
77
+ if path not in self.scripts:
78
+ self.scripts[path] = (_typed(path, self.texts[path]) if path.endswith(typescript.SUFFIXES)
79
+ else _script(path, self.texts[path]))
80
+ return self.scripts[path]
81
+
82
+ def _module(self, base):
83
+ base = posixpath.normpath(base)
84
+ for found in (base + ".py", posixpath.join(base, "__init__.py")):
85
+ if found in self.texts:
86
+ return found
87
+ return None
88
+
89
+ def _absolute(self, dotted, near):
90
+ for root in (posixpath.dirname(near) or ".", *self.roots):
91
+ found = self._module(posixpath.join(root, *dotted.split(".")))
92
+ if found:
93
+ return found
94
+ return None
95
+
96
+ def _relative(self, near, level, dotted):
97
+ base = posixpath.dirname(near)
98
+ for _ in range(level - 1):
99
+ base = posixpath.dirname(base)
100
+ return self._module(posixpath.join(base or ".", *(dotted or "").split(".")))
101
+
102
+ def _table(self, path):
103
+ if path in self.tables:
104
+ return self.tables[path]
105
+ # @req> REQ-22608177@cdmX9Aqh6XXC wgjme7
106
+ # @req> REQ-45330509@y34lW04HzVzy egzrhu
107
+ if path.endswith(SCRIPTED):
108
+ self.tables[path] = (_reading(path, self.texts[path]).table
109
+ if path.endswith(typescript.SUFFIXES)
110
+ else _declarations(self._scripted(path)[0]))
111
+ return self.tables[path]
112
+ table = self.tables[path] = {}
113
+ try:
114
+ tree = ast.parse(self.texts[path])
115
+ except (SyntaxError, ValueError):
116
+ return table
117
+ for node in _statements(tree.body):
118
+ if isinstance(node, DEFINED):
119
+ table.setdefault(node.name, []).append(("def", _span(node)))
120
+ elif isinstance(node, ASSIGNED):
121
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
122
+ for target in targets:
123
+ for name in _bound(target):
124
+ table.setdefault(name, []).append(("def", _span(node)))
125
+ elif isinstance(node, ast.Import):
126
+ for alias in node.names:
127
+ dotted = alias.name if alias.asname else alias.name.split(".")[0]
128
+ found = self._absolute(dotted, path)
129
+ if found:
130
+ table.setdefault(alias.asname or dotted, []).append(("module", found))
131
+ elif isinstance(node, ast.ImportFrom):
132
+ source = (self._relative(path, node.level, node.module) if node.level
133
+ else self._absolute(node.module or "", path))
134
+ for alias in node.names:
135
+ if source and alias.name != "*":
136
+ table.setdefault(alias.asname or alias.name, []).append(
137
+ ("from", (source, alias.name)))
138
+ return table
139
+
140
+ def _within(self, module, rest, seen):
141
+ if not rest:
142
+ return []
143
+ held = self._resolve(module, rest, seen)
144
+ if held or not module.endswith("__init__.py"):
145
+ return held
146
+ below = self._module(posixpath.join(posixpath.dirname(module), rest[0]))
147
+ return self._within(below, rest[1:], seen) if below else []
148
+
149
+ def _resolve(self, path, chain, seen=frozenset()):
150
+ head, rest = chain[0], chain[1:]
151
+ found = []
152
+ for kind, what in self._table(path).get(head, []):
153
+ if kind == "def":
154
+ found.append((path, head))
155
+ elif kind == "module":
156
+ found += self._within(what, rest, seen)
157
+ elif kind == "from" and what not in seen:
158
+ source, name = what
159
+ found += self._within(source, (name, *rest), seen | {what})
160
+ return found
161
+
162
+ def _stream(self, path):
163
+ if path not in self.streams:
164
+ held, depth = [], 0
165
+ try:
166
+ for token in tokenize.generate_tokens(io.StringIO(self.texts[path]).readline):
167
+ depth += {tokenize.INDENT: 1, tokenize.DEDENT: -1}.get(token.type, 0)
168
+ held.append((token.start[0], token.type, depth, token.string))
169
+ except (tokenize.TokenError, IndentationError, SyntaxError):
170
+ held = []
171
+ self.streams[path] = held
172
+ return self.streams[path]
173
+
174
+ def _chains(self, path, lines):
175
+ chains, chain, after_dot = [], [], False
176
+ for line, kind, _, text in self._stream(path):
177
+ if line not in lines or kind in UNREAD:
178
+ continue
179
+ if kind == tokenize.NAME and not keyword.iskeyword(text):
180
+ if after_dot:
181
+ if chain:
182
+ chain.append(text)
183
+ else:
184
+ if chain:
185
+ chains.append(tuple(chain))
186
+ chain = [text]
187
+ after_dot = False
188
+ continue
189
+ after_dot = kind == tokenize.OP and text == "."
190
+ if not after_dot and chain:
191
+ chains.append(tuple(chain))
192
+ chain = []
193
+ if chain:
194
+ chains.append(tuple(chain))
195
+ return chains
196
+
197
+ def _lines(self, key):
198
+ path, name = key
199
+ return {line for kind, span in self._table(path).get(name, []) if kind == "def"
200
+ for line in range(span[0], span[1] + 1)}
201
+
202
+ def _calls(self, path, lines):
203
+ # @req> REQ-22608177@cdmX9Aqh6XXC um45e2
204
+ # @req> REQ-71419978@R9LaJT-ldfB9 oyqqn7
205
+ if path.endswith(SCRIPTED):
206
+ pieces, table = self._scripted(path)[0], self._table(path)
207
+ return [(path, name) for name in dict.fromkeys(
208
+ piece for at, (line, piece) in enumerate(pieces)
209
+ if line in lines and piece in table and (at == 0 or pieces[at - 1][1] != "."))]
210
+ return [found for chain in dict.fromkeys(self._chains(path, lines))
211
+ for found in self._resolve(path, chain)]
212
+
213
+ def _digest(self, key):
214
+ # @req> REQ-22608177@cdmX9Aqh6XXC g2mzok
215
+ if key not in self.digests and key[0].endswith(SCRIPTED):
216
+ said, remarks = self.texts[key[0]].split("\n"), self._scripted(key[0])[1]
217
+ self.digests[key] = corpus.digest([said[n - 1].rstrip() for n in sorted(self._lines(key))
218
+ if n not in remarks])
219
+ if key not in self.digests:
220
+ lines = self._lines(key)
221
+ held, first = [], None
222
+ for line, kind, depth, text in self._stream(key[0]):
223
+ if line in lines and kind not in UNREAD:
224
+ first = depth if first is None else first
225
+ held.append([tokenize.tok_name[kind], depth - first,
226
+ "" if kind == tokenize.NEWLINE else text])
227
+ self.digests[key] = corpus.digest(
228
+ held or [self.texts[key[0]].splitlines()[n - 1] for n in sorted(lines)])
229
+ return self.digests[key]
230
+
231
+ def _closure(self, path, lines):
232
+ path = path.replace("\\", "/")
233
+ if path not in self.texts:
234
+ return set()
235
+ todo, seen = self._calls(path, set(lines)), set()
236
+ while todo:
237
+ key = todo.pop()
238
+ if key in seen:
239
+ continue
240
+ seen.add(key)
241
+ if key not in self.edges:
242
+ self.edges[key] = self._calls(key[0], self._lines(key))
243
+ todo += self.edges[key]
244
+ return seen
245
+
246
+ def taken(self, path, lines):
247
+ return [[held, name, self._digest((held, name))]
248
+ for held, name in sorted(self._closure(path, lines))]
249
+
250
+ def reached(self, path, lines):
251
+ return {(key[0], line) for key in self._closure(path, lines)
252
+ for line in self._lines(key)}
253
+ # @req- 4pmkcf
254
+
255
+
256
+ def _reading(path, text):
257
+ return typescript.read(posixpath.splitext(path)[1], text)
258
+
259
+
260
+ def remarked(text, spans):
261
+ chars = list(text)
262
+ for start, end, kept in spans:
263
+ if not kept:
264
+ _blanked(chars, start, end)
265
+ return {number for number, (said, kept) in
266
+ enumerate(zip(text.split("\n"), "".join(chars).split("\n")), start=1)
267
+ if said.strip() and not kept.strip()}
268
+
269
+
270
+ def _typed(path, text):
271
+ reading = _reading(path, text)
272
+ return reading.names, remarked(text, reading.spans)
273
+
274
+
275
+ def _blanked(chars, start, end):
276
+ for at in range(start, end):
277
+ if chars[at] != "\n":
278
+ chars[at] = " "
279
+
280
+
281
+ def _bodies(path, text):
282
+ if path.endswith(".js"):
283
+ return [(0, len(text))]
284
+ held = []
285
+ for script in CLASSIC.finditer(text):
286
+ typed = TYPED.search(script["attrs"])
287
+ kind = next((one for one in typed.groups() if one is not None), "") if typed else ""
288
+ if kind.strip().lower() in JAVASCRIPT:
289
+ held.append(script.span("body"))
290
+ return held
291
+
292
+
293
+ def _opens_pattern(text, at):
294
+ back = at - 1
295
+ while back >= 0 and text[back].isspace():
296
+ back -= 1
297
+ if back < 0 or text[back] in PATTERN_AFTER:
298
+ return True
299
+ word = back
300
+ while word >= 0 and (text[word].isalnum() or text[word] in "_$"):
301
+ word -= 1
302
+ return text[word + 1:back + 1] in PATTERN_WORDS
303
+
304
+
305
+ def _lexed(text, at, end, code, bare, closing):
306
+ depth = 0
307
+ while (found := LEXED.search(text, at, end)) is not None:
308
+ start, at = found.start(), found.end()
309
+ if found["brace"] is not None:
310
+ if closing and found[0] == "{":
311
+ depth += 1
312
+ elif closing and depth:
313
+ depth -= 1
314
+ elif closing:
315
+ return at
316
+ elif found["tick"] is not None:
317
+ at = _template(text, at, end, code, bare)
318
+ code[start] = "0"
319
+ elif found["pattern"] is not None and not _opens_pattern(text, start):
320
+ at = start + 1
321
+ else:
322
+ _blanked(code, start, at)
323
+ if found["remark"] is not None:
324
+ _blanked(bare, start, at)
325
+ else:
326
+ code[start] = "0"
327
+ return end
328
+
329
+
330
+ def _template(text, at, end, code, bare):
331
+ start = at - 1
332
+ while at < end:
333
+ if text[at] == "\\":
334
+ at += 2
335
+ elif text[at] == "`":
336
+ _blanked(code, start, at + 1)
337
+ return at + 1
338
+ elif text.startswith("${", at):
339
+ _blanked(code, start, at + 2)
340
+ at = _lexed(text, at + 2, end, code, bare, closing=True)
341
+ start = at - 1
342
+ else:
343
+ at += 1
344
+ _blanked(code, start, end)
345
+ return end
346
+
347
+
348
+ def _script(path, text):
349
+ code, bare, at = list(text), list(text), 0
350
+ for start, end in [*_bodies(path, text), (len(text), len(text))]:
351
+ for chars in (code, bare):
352
+ _blanked(chars, at, start)
353
+ _lexed(text, start, end, code, bare, closing=False)
354
+ at = end
355
+ pieces, line, last = [], 1, 0
356
+ joined = "".join(code)
357
+ for found in PIECE.finditer(joined):
358
+ line += joined.count("\n", last, found.start())
359
+ last = found.start()
360
+ pieces.append((line, found[0]))
361
+ remarks = {number for number, (said, kept) in
362
+ enumerate(zip(text.split("\n"), "".join(bare).split("\n")), start=1)
363
+ if said.strip() and not kept.strip()}
364
+ return pieces, remarks
365
+
366
+
367
+ def _declared(pieces, at):
368
+ names, depth, kind = [], 0, pieces[at][1]
369
+ at += 1
370
+ if kind == "function":
371
+ while at < len(pieces) and pieces[at][1] == "*":
372
+ at += 1
373
+ if at < len(pieces) and IDENTIFIER.match(pieces[at][1]):
374
+ names.append(pieces[at][1])
375
+ opened = False
376
+ while at < len(pieces):
377
+ line, piece = pieces[at]
378
+ opened = opened or (piece == "{" and depth == 0)
379
+ depth += (piece in OPENS) - (piece in CLOSES)
380
+ at += 1
381
+ if opened and depth == 0:
382
+ return names, line, at
383
+ return names, pieces[-1][0], at
384
+ expecting, patterned = True, False
385
+ while at < len(pieces):
386
+ line, piece = pieces[at]
387
+ if depth == 0 and (piece in BINDINGS or piece == "function" and _starts(pieces, at)):
388
+ return names, pieces[at - 1][0], at
389
+ at += 1
390
+ opening = depth == 0 and expecting and piece in ("{", "[")
391
+ if IDENTIFIER.match(piece) and (depth == 0 and expecting or patterned):
392
+ names.append(piece)
393
+ if depth == 0:
394
+ expecting = piece == ","
395
+ if piece == ";":
396
+ return names, line, at
397
+ depth += (piece in OPENS) - (piece in CLOSES)
398
+ patterned = (patterned or opening) and depth > 0
399
+ return names, pieces[-1][0], at
400
+
401
+
402
+ def _starts(pieces, at):
403
+ if at == 0:
404
+ return True
405
+ line, before = pieces[at - 1]
406
+ if before in ("async", "export", "default") and (at < 2 or pieces[at - 2][1] != "."):
407
+ return _starts(pieces, at - 1)
408
+ return before in (";", "}") or (line < pieces[at][0] and not CONTINUING.match(before))
409
+
410
+
411
+ def _declarations(pieces):
412
+ table, depth, at = {}, 0, 0
413
+ while at < len(pieces):
414
+ line, piece = pieces[at]
415
+ if depth == 0 and (piece in BINDINGS and (at == 0 or pieces[at - 1][1] != ".")
416
+ or piece == "function" and _starts(pieces, at)):
417
+ first = (pieces[at - 1][0] if piece == "function" and at
418
+ and pieces[at - 1][1] == "async" else line)
419
+ names, last, at = _declared(pieces, at)
420
+ for name in names:
421
+ table.setdefault(name, []).append(("def", (first, last)))
422
+ continue
423
+ depth += (piece in OPENS) - (piece in CLOSES)
424
+ at += 1
425
+ return table
reqctl/resolve.py ADDED
@@ -0,0 +1,74 @@
1
+ from .corpus import (CITATION_LIST, CONFLICTED, Item, ReqctlError, loads,
2
+ mapping, path_for, read_text, same, save, sides)
3
+
4
+ PINS = "assessed"
5
+ GONE = object()
6
+
7
+
8
+ def _side(text, where):
9
+ # @req> REQ-49870454@KKNV-vdY6oM1 ilmqvb
10
+ held = loads(text, where, aliases=False)
11
+ if not isinstance(held, dict):
12
+ # @req> REQ-30898926@Vu28eWaG_1mE hjcyzp
13
+ raise ReqctlError(
14
+ f"{where.stem} holds an unresolved merge, and a side of it does "
15
+ "not read as an item -- settle it by hand"
16
+ )
17
+ return held
18
+
19
+
20
+ def _reached(path, text):
21
+ ours, theirs = (_side(side, path) for side in sides(text))
22
+ pinned = all(isinstance(held.get(PINS), dict) for held in (ours, theirs))
23
+ skipped = ({PINS} if pinned else set()) | {CITATION_LIST}
24
+ spoken = {key: value for key, value in ours.items() if key not in skipped}
25
+ answered = {key: value for key, value in theirs.items() if key not in skipped}
26
+ return ours, theirs, skipped, spoken, sorted(
27
+ str(key) for key in set(spoken) | set(answered)
28
+ if not same(spoken.get(key, GONE), answered.get(key, GONE)))
29
+
30
+
31
+ # @req> REQ-38498011@eJXtv0pwMf-A rm5na2
32
+ def merged(path, text):
33
+ # @req+ REQ-38498011@eJXtv0pwMf-A 3sjv5b
34
+ try:
35
+ reached = _reached(path, text)[-1]
36
+ except ReqctlError:
37
+ # @req> REQ-30898926@Vu28eWaG_1mE damfks
38
+ return f"{path} holds an unresolved merge -- settle it by hand"
39
+ if not reached:
40
+ return (f"{path} holds an unresolved merge within its pins and citation "
41
+ f"list -- `reqctl resolve {path.stem}` settles it")
42
+ # @req> REQ-30898926@Vu28eWaG_1mE zsxafm
43
+ return (f"{path} holds an unresolved merge reaching {', '.join(reached)}, "
44
+ "outside its pins and citation list -- settle it by hand")
45
+ # @req- 3sjv5b
46
+
47
+
48
+ def resolve(store, uid):
49
+ # @req+ REQ-95299157@dJzPK-VOKyQG w4qjbn
50
+ path = path_for(store.root, uid)
51
+ text = read_text(path)
52
+ if not CONFLICTED.search(text):
53
+ # @req> REQ-30898926@Vu28eWaG_1mE wlfoj4
54
+ raise ReqctlError(f"{uid} holds no unresolved merge -- nothing to resolve")
55
+ ours, theirs, skipped, spoken, reached = _reached(path, text)
56
+ if reached:
57
+ # @req> REQ-30898926@Vu28eWaG_1mE vxs7av
58
+ raise ReqctlError(
59
+ f"{uid}: the merge reaches {', '.join(reached)}, outside its pins "
60
+ f"and citation list -- settle {'it' if len(reached) == 1 else 'them'}"
61
+ " by hand"
62
+ )
63
+ held = dict(spoken)
64
+ dropped = {}
65
+ for key in sorted(skipped):
66
+ mine, yours = mapping(ours, key), mapping(theirs, key)
67
+ kept = {name: value for name, value in mine.items()
68
+ if same(yours.get(name, GONE), value)}
69
+ if kept or key == PINS:
70
+ held[key] = kept
71
+ dropped[key] = sorted((set(mine) | set(yours)) - set(kept))
72
+ save(store, Item(uid, path, held))
73
+ return path, dropped.get(PINS, []), dropped[CITATION_LIST]
74
+ # @req- w4qjbn
@@ -0,0 +1,42 @@
1
+ # @req> REQ-97751594@BwWOFlFOjjyU dlcdx3
2
+ $schema: "https://json-schema.org/draft/2020-12/schema"
3
+ # @req> REQ-83218477@nthDaUhwd58l ch4k3s
4
+ $id: "reqctl/baseline.schema.yaml"
5
+ # @req> REQ-52846482@4k8T0YLN4n97 uuo4su
6
+ type: object
7
+ # @req> REQ-20678879@c210nefD6kHQ 3jp6m3
8
+ additionalProperties: false
9
+
10
+ # @req> REQ-10511199@UQZ6Ch88aIpW ucy2b5
11
+ # @req> REQ-85783763@3jU8EBlXjjXR u3vnla
12
+ # @req> REQ-14619822@tGPby0EQMjQz h5lv42
13
+ # @req> REQ-54257918@2pAThuxToYwU dvuoot
14
+ required: [baseline, commit, items, supersedes]
15
+
16
+ # @req> REQ-20678879@c210nefD6kHQ qke5hh
17
+ properties:
18
+
19
+ # @req> REQ-10511199@UQZ6Ch88aIpW zv72fc
20
+ baseline:
21
+ type: integer
22
+ minimum: 1
23
+
24
+ # @req> REQ-85783763@3jU8EBlXjjXR er6rq5
25
+ commit:
26
+ type: string
27
+ pattern: "^[0-9a-f]{40}$"
28
+
29
+ # @req> REQ-14619822@tGPby0EQMjQz zpbdvj
30
+ supersedes:
31
+ type: [integer, "null"]
32
+
33
+ # @req> REQ-54257918@2pAThuxToYwU cpbxhm
34
+ items:
35
+ type: object
36
+ minProperties: 1
37
+ # @req> REQ-64078604@z4ggvy8qxGN6 bwtwmt
38
+ propertyNames:
39
+ pattern: "^((REQ|GUARD|PARAM|TERM|DATA)-[0-9]{8}|[a-z][a-z0-9_]*)$"
40
+ additionalProperties:
41
+ type: string
42
+ minLength: 1