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