reqthread 0.2.0__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.
- reqthread/__init__.py +4 -0
- reqthread/__main__.py +5 -0
- reqthread/build.py +467 -0
- reqthread/cli.py +256 -0
- reqthread/formats.py +337 -0
- reqthread/gates.py +138 -0
- reqthread/ingest.py +221 -0
- reqthread/report.py +224 -0
- reqthread/scan.py +345 -0
- reqthread/schema/architecture.schema.json +13 -0
- reqthread/schema/bundle.schema.json +1149 -0
- reqthread/schema/cause.schema.json +74 -0
- reqthread/schema/config.schema.json +368 -0
- reqthread/schema/design.schema.json +17 -0
- reqthread/schema/deviations.schema.json +32 -0
- reqthread/schema/hazard.schema.json +29 -0
- reqthread/schema/requirement.schema.json +88 -0
- reqthread/schema/standard-data.schema.json +138 -0
- reqthread-0.2.0.dist-info/METADATA +148 -0
- reqthread-0.2.0.dist-info/RECORD +24 -0
- reqthread-0.2.0.dist-info/WHEEL +5 -0
- reqthread-0.2.0.dist-info/entry_points.txt +2 -0
- reqthread-0.2.0.dist-info/licenses/LICENSE +202 -0
- reqthread-0.2.0.dist-info/top_level.txt +1 -0
reqthread/scan.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Annotation scanner for C/C++ and Python sources and tests.
|
|
2
|
+
|
|
3
|
+
A tag applies to the innermost function enclosing it, otherwise to the function
|
|
4
|
+
immediately following it (only blank lines, comments or decorators between).
|
|
5
|
+
A block containing @file applies to the whole file. Anything else is an error:
|
|
6
|
+
misattributing a trace link is worse than missing one.
|
|
7
|
+
"""
|
|
8
|
+
import ast
|
|
9
|
+
import bisect
|
|
10
|
+
import io
|
|
11
|
+
import re
|
|
12
|
+
import tokenize
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
KEY_RE = re.compile(r"^[A-Z][A-Z0-9]*-[A-Z0-9][A-Z0-9._-]*$")
|
|
17
|
+
TAG_RE = re.compile(r"@(satisfies|implements|verifies|infrastructure|test-level|file)\b([^@]*)")
|
|
18
|
+
KEY_TAGS = {"satisfies", "implements", "verifies"}
|
|
19
|
+
ROLE_TAGS = {
|
|
20
|
+
"source": {"satisfies", "implements", "infrastructure", "file"},
|
|
21
|
+
"test": {"verifies", "test-level", "file"},
|
|
22
|
+
}
|
|
23
|
+
C_TEST_MACROS = {"ZTEST", "ZTEST_F", "ZTEST_USER", "ZTEST_USER_F", "TEST", "TEST_F", "TEST_P"}
|
|
24
|
+
C_NOT_FUNCTIONS = {"if", "for", "while", "switch", "return", "else", "do", "sizeof", "case", "defined"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Tag:
|
|
29
|
+
tag: str
|
|
30
|
+
value: str
|
|
31
|
+
line: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Symbol:
|
|
36
|
+
name: str
|
|
37
|
+
start: int
|
|
38
|
+
end: int
|
|
39
|
+
kind: str # function | test | class
|
|
40
|
+
parent: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Unit:
|
|
45
|
+
"""A code unit (role=source) or test case (role=test) with its resolved tags."""
|
|
46
|
+
path: str
|
|
47
|
+
symbol: str | None
|
|
48
|
+
start: int
|
|
49
|
+
end: int
|
|
50
|
+
satisfies: list = field(default_factory=list)
|
|
51
|
+
implements: list = field(default_factory=list)
|
|
52
|
+
verifies: list = field(default_factory=list)
|
|
53
|
+
infrastructure: str | None = None
|
|
54
|
+
test_level: str | None = None
|
|
55
|
+
tag_lines: dict = field(default_factory=dict) # key -> line declared, for link provenance
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _clean(value):
|
|
59
|
+
return re.sub(r"""(\*/|\"\"\"|''')\s*$""", "", value).strip().rstrip("*").strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_DECORATION = re.compile(r"^\s*(?:/\*+|\*+(?!/)|//+|#+|\"\"\"|''')?\s*")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tags_in(lines_with_numbers):
|
|
66
|
+
"""Find tags line by line. Untagged lines directly after @infrastructure continue its reason,
|
|
67
|
+
up to a blank line or the next tag."""
|
|
68
|
+
tags, open_reason = [], False
|
|
69
|
+
for lineno, text in lines_with_numbers:
|
|
70
|
+
found = list(TAG_RE.finditer(text))
|
|
71
|
+
if found:
|
|
72
|
+
tags += [Tag(m.group(1), _clean(m.group(2)), lineno) for m in found]
|
|
73
|
+
open_reason = tags[-1].tag == "infrastructure"
|
|
74
|
+
continue
|
|
75
|
+
rest = _clean(_DECORATION.sub("", text, count=1))
|
|
76
|
+
if open_reason and rest:
|
|
77
|
+
tags[-1].value = f"{tags[-1].value} {rest}".strip()
|
|
78
|
+
else:
|
|
79
|
+
open_reason = False
|
|
80
|
+
return tags
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ C / C++
|
|
84
|
+
|
|
85
|
+
def _blank(chars, a, b):
|
|
86
|
+
for k in range(a, b):
|
|
87
|
+
if chars[k] != "\n":
|
|
88
|
+
chars[k] = " "
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _strip_c(text):
|
|
92
|
+
"""Return (code view with comments/strings/preprocessor blanked, [(start_off, end_off)] of comments)."""
|
|
93
|
+
chars, comments = list(text), []
|
|
94
|
+
i, n = 0, len(text)
|
|
95
|
+
while i < n:
|
|
96
|
+
c, nxt = text[i], text[i + 1] if i + 1 < n else ""
|
|
97
|
+
if c == "/" and nxt == "/":
|
|
98
|
+
j = text.find("\n", i)
|
|
99
|
+
j = n if j == -1 else j
|
|
100
|
+
comments.append((i, j))
|
|
101
|
+
_blank(chars, i, j)
|
|
102
|
+
i = j
|
|
103
|
+
elif c == "/" and nxt == "*":
|
|
104
|
+
j = text.find("*/", i + 2)
|
|
105
|
+
j = n if j == -1 else j + 2
|
|
106
|
+
comments.append((i, j))
|
|
107
|
+
_blank(chars, i, j)
|
|
108
|
+
i = j
|
|
109
|
+
elif c in "\"'":
|
|
110
|
+
j = i + 1
|
|
111
|
+
while j < n and text[j] != c and text[j] != "\n":
|
|
112
|
+
j += 2 if text[j] == "\\" else 1
|
|
113
|
+
j = min(j + 1, n)
|
|
114
|
+
_blank(chars, i, j)
|
|
115
|
+
i = j
|
|
116
|
+
else:
|
|
117
|
+
i += 1
|
|
118
|
+
code = "".join(chars)
|
|
119
|
+
out, continuing = [], False
|
|
120
|
+
for line in code.splitlines(keepends=True):
|
|
121
|
+
if continuing or line.lstrip().startswith("#"):
|
|
122
|
+
continuing = line.rstrip("\n").rstrip().endswith("\\")
|
|
123
|
+
out.append("".join("\n" if ch == "\n" else " " for ch in line))
|
|
124
|
+
else:
|
|
125
|
+
out.append(line)
|
|
126
|
+
return "".join(out), comments
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_C_FUNC = re.compile(
|
|
130
|
+
r"^[ \t]*"
|
|
131
|
+
r"(?P<prefix>(?:[A-Za-z_]\w*[\s\*&]+)*?)" # storage class, return type, pointers; may span lines
|
|
132
|
+
r"(?P<name>[A-Za-z_~][\w:~]*)\s*"
|
|
133
|
+
r"\((?P<args>[^;{}()]*(?:\([^;{}()]*\)[^;{}()]*)*)\)\s*"
|
|
134
|
+
r"(?:const\s*)?(?:noexcept\s*)?"
|
|
135
|
+
r"\{",
|
|
136
|
+
re.M,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _match_brace(code, open_at):
|
|
141
|
+
depth = 0
|
|
142
|
+
for k in range(open_at, len(code)):
|
|
143
|
+
if code[k] == "{":
|
|
144
|
+
depth += 1
|
|
145
|
+
elif code[k] == "}":
|
|
146
|
+
depth -= 1
|
|
147
|
+
if depth == 0:
|
|
148
|
+
return k
|
|
149
|
+
return len(code) - 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _c_symbols(code, line_of):
|
|
153
|
+
syms, pos = [], 0
|
|
154
|
+
while True:
|
|
155
|
+
m = _C_FUNC.search(code, pos)
|
|
156
|
+
if not m:
|
|
157
|
+
break
|
|
158
|
+
name = m.group("name")
|
|
159
|
+
if name in C_NOT_FUNCTIONS:
|
|
160
|
+
pos = m.end()
|
|
161
|
+
continue
|
|
162
|
+
close = _match_brace(code, m.end() - 1)
|
|
163
|
+
start = line_of(m.start("prefix") if m.group("prefix").strip() else m.start("name"))
|
|
164
|
+
end = line_of(close)
|
|
165
|
+
if name in C_TEST_MACROS:
|
|
166
|
+
args = [a.strip() for a in m.group("args").split(",")]
|
|
167
|
+
if len(args) >= 2 and all(re.fullmatch(r"\w+", a) for a in args[:2]):
|
|
168
|
+
syms.append(Symbol(f"{args[0]}.{args[1]}", start, end, "test"))
|
|
169
|
+
else:
|
|
170
|
+
syms.append(Symbol(name, start, end, "function"))
|
|
171
|
+
pos = close + 1
|
|
172
|
+
return syms
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _scan_c(path, text):
|
|
176
|
+
code, comment_spans = _strip_c(text)
|
|
177
|
+
starts = [0] + [i + 1 for i, ch in enumerate(text) if ch == "\n"]
|
|
178
|
+
|
|
179
|
+
def line_of(off):
|
|
180
|
+
return bisect.bisect_right(starts, off)
|
|
181
|
+
|
|
182
|
+
symbols = _c_symbols(code, line_of)
|
|
183
|
+
raw_lines = text.splitlines()
|
|
184
|
+
code_lines = code.splitlines()
|
|
185
|
+
blocks = []
|
|
186
|
+
for a, b in comment_spans:
|
|
187
|
+
first, last = line_of(a), line_of(max(a, b - 1))
|
|
188
|
+
tags = _tags_in((ln, raw_lines[ln - 1]) for ln in range(first, last + 1))
|
|
189
|
+
if tags:
|
|
190
|
+
blocks.append((first, last, tags))
|
|
191
|
+
|
|
192
|
+
def blank(ln):
|
|
193
|
+
return ln - 1 >= len(code_lines) or not code_lines[ln - 1].strip()
|
|
194
|
+
|
|
195
|
+
return symbols, blocks, blank
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------ Python
|
|
199
|
+
|
|
200
|
+
def _scan_python(path, text):
|
|
201
|
+
tree = ast.parse(text, filename=path)
|
|
202
|
+
raw_lines = text.splitlines()
|
|
203
|
+
|
|
204
|
+
def start_of(node):
|
|
205
|
+
return min([node.lineno] + [d.lineno for d in getattr(node, "decorator_list", [])])
|
|
206
|
+
|
|
207
|
+
symbols, doc_nodes = [], [tree]
|
|
208
|
+
for node in tree.body:
|
|
209
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
210
|
+
symbols.append(Symbol(node.name, start_of(node), node.end_lineno, "function"))
|
|
211
|
+
doc_nodes.append(node)
|
|
212
|
+
elif isinstance(node, ast.ClassDef):
|
|
213
|
+
symbols.append(Symbol(node.name, start_of(node), node.end_lineno, "class"))
|
|
214
|
+
doc_nodes.append(node)
|
|
215
|
+
for sub in node.body:
|
|
216
|
+
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
217
|
+
symbols.append(Symbol(f"{node.name}.{sub.name}", start_of(sub), sub.end_lineno, "function", node.name))
|
|
218
|
+
doc_nodes.append(sub)
|
|
219
|
+
|
|
220
|
+
blocks = []
|
|
221
|
+
for node in doc_nodes:
|
|
222
|
+
body = getattr(node, "body", [])
|
|
223
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], "value", None), ast.Constant) \
|
|
224
|
+
and isinstance(body[0].value.value, str):
|
|
225
|
+
d = body[0]
|
|
226
|
+
tags = _tags_in((ln, raw_lines[ln - 1]) for ln in range(d.lineno, d.end_lineno + 1))
|
|
227
|
+
if tags:
|
|
228
|
+
blocks.append((d.lineno, d.end_lineno, tags))
|
|
229
|
+
|
|
230
|
+
comment_lines = set()
|
|
231
|
+
for tok in tokenize.generate_tokens(io.StringIO(text).readline):
|
|
232
|
+
if tok.type == tokenize.COMMENT:
|
|
233
|
+
ln = tok.start[0]
|
|
234
|
+
if not raw_lines[ln - 1][: tok.start[1]].strip():
|
|
235
|
+
comment_lines.add(ln)
|
|
236
|
+
tags = _tags_in([(ln, tok.string)])
|
|
237
|
+
if tags:
|
|
238
|
+
blocks.append((ln, ln, tags))
|
|
239
|
+
|
|
240
|
+
def blank(ln):
|
|
241
|
+
return ln - 1 >= len(raw_lines) or not raw_lines[ln - 1].strip() or ln in comment_lines
|
|
242
|
+
|
|
243
|
+
return symbols, blocks, blank
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ attribution
|
|
247
|
+
|
|
248
|
+
def _attribute(path, symbols, blocks, blank):
|
|
249
|
+
file_tags, per_symbol, errors = [], defaultdict(list), []
|
|
250
|
+
for first, last, tags in sorted(blocks, key=lambda b: b[0]):
|
|
251
|
+
if any(t.tag == "file" for t in tags):
|
|
252
|
+
file_tags += [t for t in tags if t.tag != "file"]
|
|
253
|
+
continue
|
|
254
|
+
enclosing = [s for s in symbols if s.start <= first and s.end >= last]
|
|
255
|
+
if enclosing:
|
|
256
|
+
target = max(enclosing, key=lambda s: s.start)
|
|
257
|
+
else:
|
|
258
|
+
following = [s for s in symbols if s.start > last]
|
|
259
|
+
target = min(following, key=lambda s: s.start) if following else None
|
|
260
|
+
if target is not None and not all(blank(ln) for ln in range(last + 1, target.start)):
|
|
261
|
+
target = None
|
|
262
|
+
if target is None:
|
|
263
|
+
errors.append(f"{path}:{tags[0].line}: @{tags[0].tag} is not directly above or inside a function; "
|
|
264
|
+
f"move it there, or add @file to apply it to the whole file")
|
|
265
|
+
continue
|
|
266
|
+
per_symbol[target.name] += tags
|
|
267
|
+
return file_tags, per_symbol, errors
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _apply(unit, tags, path, role, errors):
|
|
271
|
+
allowed = ROLE_TAGS[role]
|
|
272
|
+
for t in tags:
|
|
273
|
+
if t.tag not in allowed:
|
|
274
|
+
what = "test" if role == "test" else "source"
|
|
275
|
+
errors.append(f"{path}:{t.line}: @{t.tag} is not valid in a {what} file")
|
|
276
|
+
continue
|
|
277
|
+
if t.tag in KEY_TAGS:
|
|
278
|
+
keys = [k for k in re.split(r"[,\s]+", t.value) if k]
|
|
279
|
+
if not keys:
|
|
280
|
+
errors.append(f"{path}:{t.line}: @{t.tag} names no requirement or element")
|
|
281
|
+
for k in keys:
|
|
282
|
+
if not KEY_RE.match(k):
|
|
283
|
+
errors.append(f"{path}:{t.line}: @{t.tag}: {k!r} is not a valid key")
|
|
284
|
+
continue
|
|
285
|
+
bucket = getattr(unit, t.tag)
|
|
286
|
+
if k not in bucket:
|
|
287
|
+
bucket.append(k)
|
|
288
|
+
unit.tag_lines[(t.tag, k)] = t.line
|
|
289
|
+
elif t.tag == "infrastructure":
|
|
290
|
+
if not t.value:
|
|
291
|
+
errors.append(f"{path}:{t.line}: @infrastructure needs a reason")
|
|
292
|
+
else:
|
|
293
|
+
unit.infrastructure = t.value
|
|
294
|
+
elif t.tag == "test-level":
|
|
295
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+", t.value or ""):
|
|
296
|
+
errors.append(f"{path}:{t.line}: @test-level needs a single level id")
|
|
297
|
+
else:
|
|
298
|
+
unit.test_level = t.value
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def scan_file(path, text, language, role):
|
|
302
|
+
"""Scan one file. role is 'source' or 'test'. Returns (units, errors)."""
|
|
303
|
+
try:
|
|
304
|
+
if language == "python":
|
|
305
|
+
symbols, blocks, blank = _scan_python(path, text)
|
|
306
|
+
else:
|
|
307
|
+
symbols, blocks, blank = _scan_c(path, text)
|
|
308
|
+
except SyntaxError as e:
|
|
309
|
+
return [], [f"{path}:{e.lineno}: cannot parse: {e.msg}"]
|
|
310
|
+
|
|
311
|
+
file_tags, per_symbol, errors = _attribute(path, symbols, blocks, blank)
|
|
312
|
+
classes = {s.name for s in symbols if s.kind == "class"}
|
|
313
|
+
units = []
|
|
314
|
+
|
|
315
|
+
def is_test(s):
|
|
316
|
+
if s.kind == "test":
|
|
317
|
+
return True
|
|
318
|
+
last = s.name.rsplit(".", 1)[-1]
|
|
319
|
+
return s.kind == "function" and last.startswith("test") and (s.parent is None or s.parent.startswith("Test"))
|
|
320
|
+
|
|
321
|
+
for s in symbols:
|
|
322
|
+
if s.kind == "class":
|
|
323
|
+
continue
|
|
324
|
+
inherited = per_symbol.get(s.parent, []) if s.parent in classes else []
|
|
325
|
+
if role == "test":
|
|
326
|
+
if not is_test(s):
|
|
327
|
+
if per_symbol.get(s.name):
|
|
328
|
+
errors.append(f"{path}:{per_symbol[s.name][0].line}: annotation on {s.name}, which is not a test")
|
|
329
|
+
continue
|
|
330
|
+
u = Unit(path, s.name, s.start, s.end)
|
|
331
|
+
_apply(u, file_tags + inherited + per_symbol.get(s.name, []), path, role, errors)
|
|
332
|
+
else:
|
|
333
|
+
u = Unit(path, s.name, s.start, s.end)
|
|
334
|
+
_apply(u, inherited + per_symbol.get(s.name, []), path, role, errors)
|
|
335
|
+
units.append(u)
|
|
336
|
+
|
|
337
|
+
if role == "source" and file_tags:
|
|
338
|
+
last_line = max([s.end for s in symbols], default=1)
|
|
339
|
+
u = Unit(path, None, 1, max(last_line, len(text.splitlines())))
|
|
340
|
+
_apply(u, file_tags, path, role, errors)
|
|
341
|
+
units.append(u)
|
|
342
|
+
elif role == "test" and file_tags and not any(is_test(s) for s in symbols):
|
|
343
|
+
errors.append(f"{path}: @file annotations but no tests found in the file")
|
|
344
|
+
|
|
345
|
+
return units, sorted(set(errors), key=errors.index)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:evidence-bundle:schema:architecture-file:0.1.0",
|
|
4
|
+
"title": "Architecture element file front matter (*.arch.md)",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["id", "safety_related"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": { "type": "string", "pattern": "^[A-Z][A-Z0-9]*-[A-Z0-9][A-Z0-9._-]*$" },
|
|
10
|
+
"safety_related": { "type": "boolean" },
|
|
11
|
+
"integrity_level": { "type": "string" }
|
|
12
|
+
}
|
|
13
|
+
}
|