flyleaf 0.5.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.
- flyleaf/__init__.py +11 -0
- flyleaf/attribution.py +96 -0
- flyleaf/baseline.py +66 -0
- flyleaf/brief.py +427 -0
- flyleaf/card.py +161 -0
- flyleaf/citations.py +188 -0
- flyleaf/cli.py +236 -0
- flyleaf/report.py +393 -0
- flyleaf/rules.py +258 -0
- flyleaf/scan.py +503 -0
- flyleaf/severity.py +44 -0
- flyleaf/systems.py +135 -0
- flyleaf/waivers.py +124 -0
- flyleaf-0.5.0.dist-info/METADATA +438 -0
- flyleaf-0.5.0.dist-info/RECORD +18 -0
- flyleaf-0.5.0.dist-info/WHEEL +4 -0
- flyleaf-0.5.0.dist-info/entry_points.txt +3 -0
- flyleaf-0.5.0.dist-info/licenses/LICENSE +190 -0
flyleaf/scan.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Scan a tree for AI libraries and report one component per file."""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from flyleaf import DISCLAIMER, __version__
|
|
15
|
+
from flyleaf.citations import DOCUMENTATION_CITATION_IDS, pack_meta, payloads_for
|
|
16
|
+
from flyleaf.rules import ReviewHint, Rule, match_import, match_package
|
|
17
|
+
from flyleaf.systems import SYSTEMS_FILE, System, assign, claimants, load_systems
|
|
18
|
+
|
|
19
|
+
SCHEMA_VERSION = "0.5.0"
|
|
20
|
+
|
|
21
|
+
IGNORE_DIRS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
".git",
|
|
24
|
+
".hg",
|
|
25
|
+
".svn",
|
|
26
|
+
".venv",
|
|
27
|
+
"venv",
|
|
28
|
+
"node_modules",
|
|
29
|
+
"__pycache__",
|
|
30
|
+
"dist",
|
|
31
|
+
"build",
|
|
32
|
+
".tox",
|
|
33
|
+
".mypy_cache",
|
|
34
|
+
".pytest_cache",
|
|
35
|
+
".ruff_cache",
|
|
36
|
+
"site-packages",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
CARD_NAMES = (
|
|
41
|
+
"MODEL_CARD.md",
|
|
42
|
+
"model_card.md",
|
|
43
|
+
"modelcard.md",
|
|
44
|
+
"MODEL_CARD.yaml",
|
|
45
|
+
"model_card.yaml",
|
|
46
|
+
"modelcard.yaml",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_MAX_BYTES = 1_000_000
|
|
50
|
+
_GOOGLE_SUBMODULES = {"generativeai": "google.generativeai", "genai": "google.genai"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _Hit:
|
|
55
|
+
rule: Rule
|
|
56
|
+
kind: str
|
|
57
|
+
line: int
|
|
58
|
+
text: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scan_path(path: Path) -> dict:
|
|
62
|
+
"""Scan `path` and return a JSON-ready inventory.
|
|
63
|
+
|
|
64
|
+
A component is one detected framework in one file. It is not an AI system.
|
|
65
|
+
Systems are groupings a person makes later.
|
|
66
|
+
"""
|
|
67
|
+
path = path.resolve()
|
|
68
|
+
if not path.exists():
|
|
69
|
+
raise FileNotFoundError(path)
|
|
70
|
+
|
|
71
|
+
if path.is_file():
|
|
72
|
+
root = path.parent
|
|
73
|
+
files = [path]
|
|
74
|
+
else:
|
|
75
|
+
root = path
|
|
76
|
+
files = list(_walk(root))
|
|
77
|
+
|
|
78
|
+
hits: dict[tuple[str, str], list[_Hit]] = {}
|
|
79
|
+
warnings: list[dict[str, str]] = []
|
|
80
|
+
|
|
81
|
+
systems, problems = load_systems(root)
|
|
82
|
+
warnings.extend({"path": SYSTEMS_FILE.as_posix(), "message": problem} for problem in problems)
|
|
83
|
+
|
|
84
|
+
for file_path in files:
|
|
85
|
+
rel = file_path.relative_to(root).as_posix()
|
|
86
|
+
kind = _scan_kind(file_path.name)
|
|
87
|
+
if kind is None:
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
if file_path.stat().st_size > _MAX_BYTES:
|
|
91
|
+
warnings.append({"path": rel, "message": "Skipped a file larger than 1MB."})
|
|
92
|
+
continue
|
|
93
|
+
text = file_path.read_text(encoding="utf-8")
|
|
94
|
+
except (OSError, UnicodeError) as exc:
|
|
95
|
+
warnings.append({"path": rel, "message": f"Could not read file: {exc}"})
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if kind == "python":
|
|
99
|
+
found, problem = _python_hits(text)
|
|
100
|
+
elif kind == "notebook":
|
|
101
|
+
found, problem = _notebook_hits(text)
|
|
102
|
+
elif kind == "requirements":
|
|
103
|
+
found = _requirement_hits(text)
|
|
104
|
+
problem = None
|
|
105
|
+
elif kind == "pyproject":
|
|
106
|
+
found, problem = _pyproject_hits(text)
|
|
107
|
+
else:
|
|
108
|
+
found, problem = _package_json_hits(text)
|
|
109
|
+
if problem:
|
|
110
|
+
warnings.append({"path": rel, "message": problem})
|
|
111
|
+
|
|
112
|
+
for hit in found:
|
|
113
|
+
hits.setdefault((rel, hit.rule.framework), []).append(hit)
|
|
114
|
+
|
|
115
|
+
components = [
|
|
116
|
+
_component(root, rel, framework, group, assign(systems, rel))
|
|
117
|
+
for (rel, framework), group in hits.items()
|
|
118
|
+
]
|
|
119
|
+
components.sort(key=lambda item: (item["path"], item["framework"]))
|
|
120
|
+
warnings.extend(_system_warnings(systems, components))
|
|
121
|
+
warnings.sort(key=lambda item: item["path"])
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"schema_version": SCHEMA_VERSION,
|
|
125
|
+
"kind": "inventory",
|
|
126
|
+
"tool": {"name": "flyleaf", "version": __version__},
|
|
127
|
+
"disclaimer": DISCLAIMER,
|
|
128
|
+
"citation_pack": pack_meta(),
|
|
129
|
+
"citations": _used_citations(components),
|
|
130
|
+
"root": str(root),
|
|
131
|
+
"systems": [_system_block(root, system, components) for system in systems],
|
|
132
|
+
"components": components,
|
|
133
|
+
"warnings": warnings,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _walk(root: Path) -> list[Path]:
|
|
138
|
+
found: list[Path] = []
|
|
139
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
140
|
+
dirnames[:] = sorted(
|
|
141
|
+
name
|
|
142
|
+
for name in dirnames
|
|
143
|
+
if name not in IGNORE_DIRS and not name.endswith(".egg-info")
|
|
144
|
+
)
|
|
145
|
+
for filename in sorted(filenames):
|
|
146
|
+
found.append(Path(dirpath) / filename)
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_requirements(name: str) -> bool:
|
|
151
|
+
return name == "requirements.txt" or (
|
|
152
|
+
name.startswith("requirements") and name.endswith(".txt")
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _scan_kind(name: str) -> str | None:
|
|
157
|
+
if name.endswith(".py"):
|
|
158
|
+
return "python"
|
|
159
|
+
if name.endswith(".ipynb"):
|
|
160
|
+
return "notebook"
|
|
161
|
+
if _is_requirements(name):
|
|
162
|
+
return "requirements"
|
|
163
|
+
if name == "pyproject.toml":
|
|
164
|
+
return "pyproject"
|
|
165
|
+
if name == "package.json":
|
|
166
|
+
return "package_json"
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _python_hits(text: str) -> tuple[list[_Hit], str | None]:
|
|
171
|
+
try:
|
|
172
|
+
tree = ast.parse(text)
|
|
173
|
+
except SyntaxError as exc:
|
|
174
|
+
return [], f"Could not parse Python: {exc.msg}"
|
|
175
|
+
|
|
176
|
+
hits: list[_Hit] = []
|
|
177
|
+
seen: set[tuple[str, int, str]] = set()
|
|
178
|
+
for line, module, snippet in _iter_imports(tree):
|
|
179
|
+
rule = match_import(module)
|
|
180
|
+
if rule is None:
|
|
181
|
+
continue
|
|
182
|
+
key = (rule.framework, line, snippet)
|
|
183
|
+
if key in seen:
|
|
184
|
+
continue
|
|
185
|
+
seen.add(key)
|
|
186
|
+
hits.append(_Hit(rule=rule, kind="import", line=line, text=snippet))
|
|
187
|
+
return hits, None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _notebook_hits(text: str) -> tuple[list[_Hit], str | None]:
|
|
191
|
+
try:
|
|
192
|
+
data = json.loads(text)
|
|
193
|
+
except json.JSONDecodeError as exc:
|
|
194
|
+
return [], f"Could not parse notebook: {exc.msg}"
|
|
195
|
+
if not isinstance(data, dict):
|
|
196
|
+
return [], "Notebook was not an object."
|
|
197
|
+
|
|
198
|
+
cells = data.get("cells")
|
|
199
|
+
if not isinstance(cells, list):
|
|
200
|
+
return [], None
|
|
201
|
+
|
|
202
|
+
hits: list[_Hit] = []
|
|
203
|
+
seen: set[tuple[str, str]] = set()
|
|
204
|
+
for cell in cells:
|
|
205
|
+
if not isinstance(cell, dict) or cell.get("cell_type") != "code":
|
|
206
|
+
continue
|
|
207
|
+
code = _cell_source(cell.get("source"))
|
|
208
|
+
if not code.strip():
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
tree = ast.parse(_strip_magics(code))
|
|
212
|
+
except SyntaxError:
|
|
213
|
+
continue
|
|
214
|
+
for _line, module, snippet in _iter_imports(tree):
|
|
215
|
+
rule = match_import(module)
|
|
216
|
+
if rule is None:
|
|
217
|
+
continue
|
|
218
|
+
key = (rule.framework, snippet)
|
|
219
|
+
if key in seen:
|
|
220
|
+
continue
|
|
221
|
+
seen.add(key)
|
|
222
|
+
line_no, _ = _find_dep_line(text, snippet, spec=snippet)
|
|
223
|
+
hits.append(_Hit(rule=rule, kind="import", line=line_no, text=snippet))
|
|
224
|
+
return hits, None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _cell_source(source) -> str:
|
|
228
|
+
if isinstance(source, list):
|
|
229
|
+
return "".join(str(part) for part in source)
|
|
230
|
+
if isinstance(source, str):
|
|
231
|
+
return source
|
|
232
|
+
return ""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _strip_magics(code: str) -> str:
|
|
236
|
+
kept: list[str] = []
|
|
237
|
+
for line in code.splitlines():
|
|
238
|
+
if line.lstrip().startswith(("%", "!")):
|
|
239
|
+
kept.append("")
|
|
240
|
+
else:
|
|
241
|
+
kept.append(line)
|
|
242
|
+
return "\n".join(kept)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _iter_imports(tree: ast.AST):
|
|
246
|
+
for node in ast.walk(tree):
|
|
247
|
+
if isinstance(node, ast.Import):
|
|
248
|
+
for alias in node.names:
|
|
249
|
+
yield node.lineno, alias.name, f"import {alias.name}"
|
|
250
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
251
|
+
names = ", ".join(alias.name for alias in node.names)
|
|
252
|
+
snippet = f"from {node.module} import {names}"
|
|
253
|
+
if node.module == "google":
|
|
254
|
+
for alias in node.names:
|
|
255
|
+
mapped = _GOOGLE_SUBMODULES.get(alias.name)
|
|
256
|
+
if mapped:
|
|
257
|
+
yield node.lineno, mapped, snippet
|
|
258
|
+
continue
|
|
259
|
+
yield node.lineno, node.module, snippet
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _requirement_hits(text: str) -> list[_Hit]:
|
|
263
|
+
hits: list[_Hit] = []
|
|
264
|
+
for line_no, raw in enumerate(text.splitlines(), 1):
|
|
265
|
+
stripped = raw.strip()
|
|
266
|
+
if not stripped or stripped.startswith(("#", "-")):
|
|
267
|
+
continue
|
|
268
|
+
name = _requirement_name(stripped)
|
|
269
|
+
if not name:
|
|
270
|
+
continue
|
|
271
|
+
rule = match_package(_canon(name))
|
|
272
|
+
if rule is None:
|
|
273
|
+
continue
|
|
274
|
+
hits.append(_Hit(rule=rule, kind="dependency", line=line_no, text=stripped))
|
|
275
|
+
return hits
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _requirement_name(spec: str) -> str:
|
|
279
|
+
name = re.split(r"[<>=!~;\s\[]", spec, maxsplit=1)[0].strip()
|
|
280
|
+
if not name or name.startswith(("git+", "http://", "https://")):
|
|
281
|
+
return ""
|
|
282
|
+
return name
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _pyproject_hits(text: str) -> tuple[list[_Hit], str | None]:
|
|
286
|
+
try:
|
|
287
|
+
data = tomllib.loads(text)
|
|
288
|
+
except tomllib.TOMLDecodeError as exc:
|
|
289
|
+
return [], f"Could not parse pyproject.toml: {exc}"
|
|
290
|
+
|
|
291
|
+
entries: list[tuple[str, str | None]] = []
|
|
292
|
+
project = data.get("project")
|
|
293
|
+
if isinstance(project, dict):
|
|
294
|
+
entries.extend(_string_deps(project.get("dependencies")))
|
|
295
|
+
optional = project.get("optional-dependencies")
|
|
296
|
+
if isinstance(optional, dict):
|
|
297
|
+
for group in optional.values():
|
|
298
|
+
entries.extend(_string_deps(group))
|
|
299
|
+
groups = data.get("dependency-groups")
|
|
300
|
+
if isinstance(groups, dict):
|
|
301
|
+
for group in groups.values():
|
|
302
|
+
entries.extend(_string_deps(group))
|
|
303
|
+
poetry = (data.get("tool") or {}).get("poetry") if isinstance(data.get("tool"), dict) else None
|
|
304
|
+
if isinstance(poetry, dict):
|
|
305
|
+
entries.extend(_poetry_names(poetry.get("dependencies")))
|
|
306
|
+
group_table = poetry.get("group")
|
|
307
|
+
if isinstance(group_table, dict):
|
|
308
|
+
for group in group_table.values():
|
|
309
|
+
if isinstance(group, dict):
|
|
310
|
+
entries.extend(_poetry_names(group.get("dependencies")))
|
|
311
|
+
|
|
312
|
+
return _hits_for_names(text, entries), None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _string_deps(value) -> list[tuple[str, str | None]]:
|
|
316
|
+
if not isinstance(value, list):
|
|
317
|
+
return []
|
|
318
|
+
entries: list[tuple[str, str | None]] = []
|
|
319
|
+
for item in value:
|
|
320
|
+
if isinstance(item, str):
|
|
321
|
+
spec = item.strip()
|
|
322
|
+
name = _requirement_name(spec)
|
|
323
|
+
if name:
|
|
324
|
+
entries.append((name, spec))
|
|
325
|
+
return entries
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _poetry_names(value) -> list[tuple[str, str | None]]:
|
|
329
|
+
if not isinstance(value, dict):
|
|
330
|
+
return []
|
|
331
|
+
return [(name, None) for name in value if name.lower() != "python"]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _package_json_hits(text: str) -> tuple[list[_Hit], str | None]:
|
|
335
|
+
try:
|
|
336
|
+
data = json.loads(text)
|
|
337
|
+
except json.JSONDecodeError as exc:
|
|
338
|
+
return [], f"Could not parse package.json: {exc.msg}"
|
|
339
|
+
if not isinstance(data, dict):
|
|
340
|
+
return [], "package.json was not an object."
|
|
341
|
+
|
|
342
|
+
entries: list[tuple[str, str | None]] = []
|
|
343
|
+
for key in ("dependencies", "devDependencies", "optionalDependencies"):
|
|
344
|
+
block = data.get(key)
|
|
345
|
+
if isinstance(block, dict):
|
|
346
|
+
entries.extend((str(name), None) for name in block)
|
|
347
|
+
return _hits_for_names(text, entries), None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _hits_for_names(text: str, entries: list[tuple[str, str | None]]) -> list[_Hit]:
|
|
351
|
+
hits: list[_Hit] = []
|
|
352
|
+
seen: set[str] = set()
|
|
353
|
+
for name, spec in entries:
|
|
354
|
+
canonical = _canon(name)
|
|
355
|
+
if canonical in seen:
|
|
356
|
+
continue
|
|
357
|
+
rule = match_package(canonical)
|
|
358
|
+
if rule is None:
|
|
359
|
+
continue
|
|
360
|
+
seen.add(canonical)
|
|
361
|
+
line, snippet = _find_dep_line(text, name, spec)
|
|
362
|
+
hits.append(_Hit(rule=rule, kind="dependency", line=line, text=snippet))
|
|
363
|
+
return hits
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _find_dep_line(text: str, name: str, spec: str | None = None) -> tuple[int, str]:
|
|
367
|
+
"""Find a dependency or import line.
|
|
368
|
+
|
|
369
|
+
A bare mention, such as a keyword list, is not evidence. Prefer the
|
|
370
|
+
requirement string, then a line shaped like a dependency declaration.
|
|
371
|
+
"""
|
|
372
|
+
lines = text.splitlines()
|
|
373
|
+
if spec:
|
|
374
|
+
for line_no, raw in enumerate(lines, 1):
|
|
375
|
+
if spec in raw:
|
|
376
|
+
return line_no, raw.strip()
|
|
377
|
+
declaration = re.compile(
|
|
378
|
+
rf"(?i)(^|\s|[\"']){re.escape(name)}(\s*[<>=!~\[]|\s*=|\s*\"\s*:)"
|
|
379
|
+
)
|
|
380
|
+
for line_no, raw in enumerate(lines, 1):
|
|
381
|
+
if declaration.search(raw):
|
|
382
|
+
return line_no, raw.strip()
|
|
383
|
+
return 1, spec or name
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _canon(name: str) -> str:
|
|
387
|
+
name = name.strip().lower()
|
|
388
|
+
if name.startswith("@"):
|
|
389
|
+
return name
|
|
390
|
+
return re.sub(r"[-_.]+", "-", name)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _component(
|
|
394
|
+
root: Path,
|
|
395
|
+
rel: str,
|
|
396
|
+
framework: str,
|
|
397
|
+
hits: list[_Hit],
|
|
398
|
+
system: System | None,
|
|
399
|
+
) -> dict:
|
|
400
|
+
rule = hits[0].rule
|
|
401
|
+
evidence = [
|
|
402
|
+
{"kind": hit.kind, "line": hit.line, "text": hit.text}
|
|
403
|
+
for hit in sorted(hits, key=lambda hit: (hit.line, hit.text))
|
|
404
|
+
]
|
|
405
|
+
card = _resolve_card(root, rel, system)
|
|
406
|
+
return {
|
|
407
|
+
"id": f"{rel}:{framework}",
|
|
408
|
+
"path": rel,
|
|
409
|
+
"framework": framework,
|
|
410
|
+
"category": rule.category,
|
|
411
|
+
"role_hint": rule.role_hint,
|
|
412
|
+
"system": system.name if system else None,
|
|
413
|
+
"evidence": evidence,
|
|
414
|
+
"review_hints": [_hint(hint) for hint in rule.review_hints],
|
|
415
|
+
"model_card_status": "present" if card else "missing",
|
|
416
|
+
"model_card_path": card.relative_to(root).as_posix() if card else None,
|
|
417
|
+
"model_card_sha256": hashlib.sha256(card.read_bytes()).hexdigest() if card else None,
|
|
418
|
+
"documentation_citation_ids": list(DOCUMENTATION_CITATION_IDS),
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _resolve_card(root: Path, rel: str, system: System | None) -> Path | None:
|
|
423
|
+
"""A declared system card is the answer for every component in it.
|
|
424
|
+
|
|
425
|
+
One system, one card. Without a declared card, fall back to looking beside
|
|
426
|
+
the file and at the repository root.
|
|
427
|
+
"""
|
|
428
|
+
if system is not None and system.card:
|
|
429
|
+
candidate = root / system.card
|
|
430
|
+
return candidate if candidate.is_file() else None
|
|
431
|
+
return _find_card(root / rel, root)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _system_block(root: Path, system: System, components: list[dict]) -> dict:
|
|
435
|
+
members = [item for item in components if item["system"] == system.name]
|
|
436
|
+
card = (root / system.card) if system.card else None
|
|
437
|
+
present = bool(card and card.is_file())
|
|
438
|
+
block = system.payload()
|
|
439
|
+
block.update(
|
|
440
|
+
{
|
|
441
|
+
"model_card_status": "present" if present else ("missing" if card else "undeclared"),
|
|
442
|
+
"model_card_path": system.card if present else None,
|
|
443
|
+
"model_card_sha256": hashlib.sha256(card.read_bytes()).hexdigest() if present else None,
|
|
444
|
+
"component_ids": [item["id"] for item in members],
|
|
445
|
+
"frameworks": sorted({item["framework"] for item in members}),
|
|
446
|
+
}
|
|
447
|
+
)
|
|
448
|
+
return block
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _system_warnings(systems: list[System], components: list[dict]) -> list[dict[str, str]]:
|
|
452
|
+
warnings: list[dict[str, str]] = []
|
|
453
|
+
label = SYSTEMS_FILE.as_posix()
|
|
454
|
+
claimed = {item["system"] for item in components}
|
|
455
|
+
for system in systems:
|
|
456
|
+
if system.name not in claimed:
|
|
457
|
+
warnings.append(
|
|
458
|
+
{
|
|
459
|
+
"path": label,
|
|
460
|
+
"message": (
|
|
461
|
+
f"System '{system.name}' claims no detected component. "
|
|
462
|
+
"Check the includes patterns."
|
|
463
|
+
),
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
for path in dict.fromkeys(item["path"] for item in components):
|
|
467
|
+
names = claimants(systems, path)
|
|
468
|
+
if len(names) > 1:
|
|
469
|
+
warnings.append(
|
|
470
|
+
{
|
|
471
|
+
"path": path,
|
|
472
|
+
"message": (
|
|
473
|
+
f"Claimed by {', '.join(names)}. The first declared system, "
|
|
474
|
+
f"'{names[0]}', takes it."
|
|
475
|
+
),
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
return warnings
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _hint(hint: ReviewHint) -> dict:
|
|
482
|
+
return {"id": hint.id, "text": hint.text, "citation_ids": list(hint.citation_ids)}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _used_citations(components: list[dict]) -> dict[str, dict[str, str]]:
|
|
486
|
+
citation_ids: set[str] = set()
|
|
487
|
+
for component in components:
|
|
488
|
+
citation_ids.update(component["documentation_citation_ids"])
|
|
489
|
+
for hint in component["review_hints"]:
|
|
490
|
+
citation_ids.update(hint["citation_ids"])
|
|
491
|
+
return payloads_for(citation_ids)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _find_card(component_file: Path, root: Path) -> Path | None:
|
|
495
|
+
directories = [component_file.parent]
|
|
496
|
+
if root not in directories:
|
|
497
|
+
directories.append(root)
|
|
498
|
+
for directory in directories:
|
|
499
|
+
for name in CARD_NAMES:
|
|
500
|
+
candidate = directory / name
|
|
501
|
+
if candidate.is_file():
|
|
502
|
+
return candidate
|
|
503
|
+
return None
|
flyleaf/severity.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Severity and impact for a finding.
|
|
4
|
+
|
|
5
|
+
Severity ranks how much engineering attention a change deserves. It is not a
|
|
6
|
+
legal risk tier, and it says nothing about whether an obligation applies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
SEVERITY_NOTE = (
|
|
10
|
+
"Severity ranks engineering attention, not legal risk. "
|
|
11
|
+
"It does not indicate a risk tier under the EU AI Act."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
ORDER = ("low", "medium", "high")
|
|
15
|
+
|
|
16
|
+
# change -> (impact, severity)
|
|
17
|
+
# runtime means the detected code changed. documentation means only the card did.
|
|
18
|
+
GRADES: dict[str, tuple[str, str]] = {
|
|
19
|
+
"added_undocumented": ("runtime", "high"),
|
|
20
|
+
"added": ("runtime", "medium"),
|
|
21
|
+
"removed": ("runtime", "medium"),
|
|
22
|
+
"evidence_changed": ("runtime", "high"),
|
|
23
|
+
"card_stale": ("runtime", "high"),
|
|
24
|
+
"evidence_and_card_changed": ("runtime", "medium"),
|
|
25
|
+
"card_removed": ("documentation", "high"),
|
|
26
|
+
"card_added": ("documentation", "low"),
|
|
27
|
+
"card_changed": ("documentation", "low"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def grade(change: str, documented: bool) -> tuple[str, str]:
|
|
32
|
+
key = "added_undocumented" if change == "added" and not documented else change
|
|
33
|
+
return GRADES.get(key, ("documentation", "low"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rank(severity: str) -> int:
|
|
37
|
+
try:
|
|
38
|
+
return ORDER.index(severity)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def at_least(severity: str, threshold: str) -> bool:
|
|
44
|
+
return rank(severity) >= rank(threshold)
|
flyleaf/systems.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Systems are groupings a person declares. flyleaf never infers them.
|
|
4
|
+
|
|
5
|
+
A component is evidence: one framework in one file. A system is the thing a
|
|
6
|
+
person documents. Which files belong together depends on purpose and audience,
|
|
7
|
+
which are not in the code, so the grouping is read from a file in the
|
|
8
|
+
repository rather than guessed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
SYSTEMS_FILE = Path(".flyleaf") / "systems.toml"
|
|
17
|
+
REQUIRED_FIELDS = ("name", "includes")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class System:
|
|
22
|
+
name: str
|
|
23
|
+
includes: tuple[str, ...]
|
|
24
|
+
card: str | None
|
|
25
|
+
owner: str | None
|
|
26
|
+
|
|
27
|
+
def matches(self, path: str) -> bool:
|
|
28
|
+
return any(_match(path, pattern) for pattern in self.includes)
|
|
29
|
+
|
|
30
|
+
def payload(self) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"name": self.name,
|
|
33
|
+
"owner": self.owner,
|
|
34
|
+
"card": self.card,
|
|
35
|
+
"includes": list(self.includes),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_systems(root: Path) -> tuple[list[System], list[str]]:
|
|
40
|
+
"""Read `.flyleaf/systems.toml`. Return the valid systems and any problems.
|
|
41
|
+
|
|
42
|
+
A malformed entry is reported and ignored. Its files then stand alone as
|
|
43
|
+
components, which is the behaviour when no systems file exists at all.
|
|
44
|
+
"""
|
|
45
|
+
path = root / SYSTEMS_FILE
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
return [], []
|
|
48
|
+
try:
|
|
49
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
51
|
+
return [], [f"Could not read {SYSTEMS_FILE.as_posix()}: {exc}"]
|
|
52
|
+
|
|
53
|
+
entries = data.get("system")
|
|
54
|
+
if entries is None:
|
|
55
|
+
return [], []
|
|
56
|
+
if not isinstance(entries, list):
|
|
57
|
+
return [], [f"{SYSTEMS_FILE.as_posix()}: system must be a list of tables."]
|
|
58
|
+
|
|
59
|
+
systems: list[System] = []
|
|
60
|
+
problems: list[str] = []
|
|
61
|
+
seen: set[str] = set()
|
|
62
|
+
for index, entry in enumerate(entries, 1):
|
|
63
|
+
system, problem = _parse(entry, index)
|
|
64
|
+
if problem:
|
|
65
|
+
problems.append(problem)
|
|
66
|
+
if system is None:
|
|
67
|
+
continue
|
|
68
|
+
if system.name in seen:
|
|
69
|
+
problems.append(
|
|
70
|
+
f"{SYSTEMS_FILE.as_posix()} entry {index}: ignored because the name "
|
|
71
|
+
f"'{system.name}' is already used."
|
|
72
|
+
)
|
|
73
|
+
continue
|
|
74
|
+
seen.add(system.name)
|
|
75
|
+
systems.append(system)
|
|
76
|
+
return systems, problems
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse(entry, index: int) -> tuple[System | None, str | None]:
|
|
80
|
+
label = f"{SYSTEMS_FILE.as_posix()} entry {index}"
|
|
81
|
+
if not isinstance(entry, dict):
|
|
82
|
+
return None, f"{label}: not a table."
|
|
83
|
+
|
|
84
|
+
missing = [field for field in REQUIRED_FIELDS if not entry.get(field)]
|
|
85
|
+
if missing:
|
|
86
|
+
return None, (
|
|
87
|
+
f"{label}: ignored because it is missing {', '.join(missing)}. "
|
|
88
|
+
"A system needs a name and a list of includes."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
includes = entry["includes"]
|
|
92
|
+
if isinstance(includes, str):
|
|
93
|
+
includes = [includes]
|
|
94
|
+
if not isinstance(includes, list) or not all(isinstance(item, str) for item in includes):
|
|
95
|
+
return None, f"{label}: includes must be a list of path patterns."
|
|
96
|
+
patterns = tuple(item.strip() for item in includes if item.strip())
|
|
97
|
+
if not patterns:
|
|
98
|
+
return None, f"{label}: includes must name at least one path pattern."
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
System(
|
|
102
|
+
name=str(entry["name"]),
|
|
103
|
+
includes=patterns,
|
|
104
|
+
card=str(entry["card"]) if entry.get("card") else None,
|
|
105
|
+
owner=str(entry["owner"]) if entry.get("owner") else None,
|
|
106
|
+
),
|
|
107
|
+
None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def assign(systems: list[System], path: str) -> System | None:
|
|
112
|
+
"""Return the system that claims `path`. The first declared match wins."""
|
|
113
|
+
for system in systems:
|
|
114
|
+
if system.matches(path):
|
|
115
|
+
return system
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def claimants(systems: list[System], path: str) -> list[str]:
|
|
120
|
+
"""Every system that claims `path`, in declaration order."""
|
|
121
|
+
return [system.name for system in systems if system.matches(path)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _match(path: str, pattern: str) -> bool:
|
|
125
|
+
"""Match a repository-relative posix path against one include pattern.
|
|
126
|
+
|
|
127
|
+
An exact path, a glob, or a directory prefix all work. A `*` spans
|
|
128
|
+
directory separators, so `src/*` claims everything under `src`.
|
|
129
|
+
"""
|
|
130
|
+
if path == pattern:
|
|
131
|
+
return True
|
|
132
|
+
if fnmatch.fnmatchcase(path, pattern):
|
|
133
|
+
return True
|
|
134
|
+
prefix = pattern.rstrip("/")
|
|
135
|
+
return bool(prefix) and path.startswith(prefix + "/")
|