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