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/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Inventory AI components in a repository and list areas to review."""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.5.0"
|
|
6
|
+
|
|
7
|
+
DISCLAIMER = (
|
|
8
|
+
"flyleaf lists AI components and areas to review. "
|
|
9
|
+
"Legal classification under the EU AI Act depends on the use case, "
|
|
10
|
+
"which code cannot determine. This output is not legal advice."
|
|
11
|
+
)
|
flyleaf/attribution.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Who should look at a finding.
|
|
4
|
+
|
|
5
|
+
Two different names, deliberately kept apart. The owner is declared in
|
|
6
|
+
`.flyleaf/systems.toml` and is accountable for the system. The author is read
|
|
7
|
+
from git blame and is whoever last touched the evidence line. The author is
|
|
8
|
+
the person who can answer the question now. The owner is the person who is
|
|
9
|
+
answerable for it either way.
|
|
10
|
+
|
|
11
|
+
Blame names a line, not a responsibility. A reformat, a rename, or a bot
|
|
12
|
+
bumping a version will make the wrong person the author, so the report says
|
|
13
|
+
where the name came from and never calls it fault.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
BOT_PATTERN = re.compile(r"(?i)\[bot\]|^(dependabot|renovate|github-actions)\b")
|
|
21
|
+
_EMPTY_SHA = "0" * 40
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def last_author(repo: Path, rev: str | None, path: str, line: int) -> dict | None:
|
|
25
|
+
"""Return who last changed `line` of `path`, or None when git cannot say.
|
|
26
|
+
|
|
27
|
+
`rev` blames at a revision. None blames the working tree, where an
|
|
28
|
+
uncommitted edit has no author yet.
|
|
29
|
+
"""
|
|
30
|
+
args = ["blame", "--porcelain", "-L", f"{max(1, line)},{max(1, line)}"]
|
|
31
|
+
if rev:
|
|
32
|
+
args.append(rev)
|
|
33
|
+
args.extend(["--", path])
|
|
34
|
+
try:
|
|
35
|
+
completed = subprocess.run(
|
|
36
|
+
["git", "-C", str(repo), *args],
|
|
37
|
+
check=True,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
)
|
|
41
|
+
except (OSError, subprocess.CalledProcessError):
|
|
42
|
+
return None
|
|
43
|
+
return _parse(completed.stdout)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse(output: str) -> dict | None:
|
|
47
|
+
lines = output.splitlines()
|
|
48
|
+
if not lines:
|
|
49
|
+
return None
|
|
50
|
+
commit = lines[0].split(" ", 1)[0]
|
|
51
|
+
fields: dict[str, str] = {}
|
|
52
|
+
for raw in lines[1:]:
|
|
53
|
+
if raw.startswith("\t"):
|
|
54
|
+
break
|
|
55
|
+
key, _, value = raw.partition(" ")
|
|
56
|
+
fields.setdefault(key, value)
|
|
57
|
+
|
|
58
|
+
name = fields.get("author", "").strip()
|
|
59
|
+
email = fields.get("author-mail", "").strip().strip("<>")
|
|
60
|
+
uncommitted = commit == _EMPTY_SHA
|
|
61
|
+
if uncommitted:
|
|
62
|
+
return {
|
|
63
|
+
"name": None,
|
|
64
|
+
"email": None,
|
|
65
|
+
"commit": None,
|
|
66
|
+
"summary": None,
|
|
67
|
+
"uncommitted": True,
|
|
68
|
+
"is_bot": False,
|
|
69
|
+
"source": "git blame",
|
|
70
|
+
}
|
|
71
|
+
if not name and not email:
|
|
72
|
+
return None
|
|
73
|
+
return {
|
|
74
|
+
"name": name or None,
|
|
75
|
+
"email": email or None,
|
|
76
|
+
"commit": commit[:12],
|
|
77
|
+
"summary": fields.get("summary") or None,
|
|
78
|
+
"uncommitted": False,
|
|
79
|
+
"is_bot": _is_bot(name, email),
|
|
80
|
+
"source": "git blame",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_bot(name: str, email: str) -> bool:
|
|
85
|
+
return bool(BOT_PATTERN.search(name or "") or BOT_PATTERN.search(email or ""))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def label(author: dict | None) -> str:
|
|
89
|
+
"""A short human label for a report line."""
|
|
90
|
+
if author is None:
|
|
91
|
+
return "unknown"
|
|
92
|
+
if author["uncommitted"]:
|
|
93
|
+
return "uncommitted change"
|
|
94
|
+
who = author["email"] or author["name"] or "unknown"
|
|
95
|
+
suffix = " (bot)" if author["is_bot"] else ""
|
|
96
|
+
return f"{who}{suffix}"
|
flyleaf/baseline.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""A baseline is the inventory as it stood at the last sign-off."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC, date, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flyleaf import __version__
|
|
10
|
+
from flyleaf.scan import scan_path
|
|
11
|
+
|
|
12
|
+
BASELINE_FILE = Path(".flyleaf") / "baseline.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaselineError(Exception):
|
|
16
|
+
"""The baseline could not be read."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def baseline_path(repo: Path) -> Path:
|
|
20
|
+
return repo / BASELINE_FILE
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_baseline(repo: Path, rev: str | None, approved_by: str | None) -> Path:
|
|
24
|
+
"""Record the current tree as the approved state."""
|
|
25
|
+
inventory = scan_path(repo)
|
|
26
|
+
document = {
|
|
27
|
+
"kind": "baseline",
|
|
28
|
+
"tool": {"name": "flyleaf", "version": __version__},
|
|
29
|
+
"recorded_at": datetime.now(UTC).date().isoformat(),
|
|
30
|
+
"rev": rev,
|
|
31
|
+
"approved_by": approved_by,
|
|
32
|
+
"inventory": inventory,
|
|
33
|
+
}
|
|
34
|
+
destination = baseline_path(repo)
|
|
35
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
destination.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
|
37
|
+
return destination
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_baseline(repo: Path) -> dict:
|
|
41
|
+
path = baseline_path(repo)
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
raise BaselineError(
|
|
44
|
+
f"No baseline at {BASELINE_FILE.as_posix()}. Run 'flyleaf baseline' to record one."
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
49
|
+
raise BaselineError(f"Could not read {BASELINE_FILE.as_posix()}: {exc}") from exc
|
|
50
|
+
inventory = document.get("inventory")
|
|
51
|
+
if not isinstance(inventory, dict) or "components" not in inventory:
|
|
52
|
+
raise BaselineError(f"{BASELINE_FILE.as_posix()} does not contain an inventory.")
|
|
53
|
+
return document
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def describe(document: dict) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"ref": "baseline",
|
|
59
|
+
"rev": document.get("rev"),
|
|
60
|
+
"recorded_at": document.get("recorded_at"),
|
|
61
|
+
"approved_by": document.get("approved_by"),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def today() -> date:
|
|
66
|
+
return datetime.now(UTC).date()
|
flyleaf/brief.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Compare two inventories and list documentation changes with citations."""
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from flyleaf import DISCLAIMER, __version__, attribution
|
|
15
|
+
from flyleaf import baseline as baseline_store
|
|
16
|
+
from flyleaf import severity as severity_grades
|
|
17
|
+
from flyleaf import waivers as waiver_store
|
|
18
|
+
from flyleaf.citations import pack_meta, payloads_for
|
|
19
|
+
from flyleaf.scan import SCHEMA_VERSION, scan_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GitError(Exception):
|
|
23
|
+
"""The repository could not be read with git."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_brief(
|
|
27
|
+
start: Path,
|
|
28
|
+
base: str | None,
|
|
29
|
+
head: str | None,
|
|
30
|
+
use_baseline: bool = False,
|
|
31
|
+
today: date | None = None,
|
|
32
|
+
blame: bool = True,
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Diff a base state against a head state.
|
|
35
|
+
|
|
36
|
+
The base is a git ref, or the recorded baseline when `use_baseline` is set.
|
|
37
|
+
The head is a git ref, or the working tree when `head` is omitted.
|
|
38
|
+
"""
|
|
39
|
+
if not start.exists():
|
|
40
|
+
raise FileNotFoundError(start)
|
|
41
|
+
repo = _git_root(start)
|
|
42
|
+
now = today or baseline_store.today()
|
|
43
|
+
|
|
44
|
+
if use_baseline:
|
|
45
|
+
document = baseline_store.read_baseline(repo)
|
|
46
|
+
base_inventory = document["inventory"]
|
|
47
|
+
base_meta = baseline_store.describe(document)
|
|
48
|
+
else:
|
|
49
|
+
base_rev = _rev_parse(repo, base or "HEAD")
|
|
50
|
+
base_inventory = _scan_ref(repo, base_rev)
|
|
51
|
+
base_meta = {"ref": base, "rev": base_rev}
|
|
52
|
+
|
|
53
|
+
if head is None:
|
|
54
|
+
head_meta = {
|
|
55
|
+
"ref": "working tree",
|
|
56
|
+
"rev": _rev_parse(repo, "HEAD"),
|
|
57
|
+
"dirty": bool(_git(repo, "status", "--porcelain")),
|
|
58
|
+
}
|
|
59
|
+
head_inventory = scan_path(repo)
|
|
60
|
+
else:
|
|
61
|
+
head_rev = _rev_parse(repo, head)
|
|
62
|
+
head_meta = {"ref": head, "rev": head_rev, "dirty": False}
|
|
63
|
+
head_inventory = _scan_ref(repo, head_rev)
|
|
64
|
+
|
|
65
|
+
found = _diff(base_inventory, head_inventory)
|
|
66
|
+
_attribute(repo, found, head_inventory, head_meta, base_meta, blame)
|
|
67
|
+
|
|
68
|
+
loaded, warnings = waiver_store.load_waivers(repo)
|
|
69
|
+
findings, waived = _partition(found, loaded, now)
|
|
70
|
+
findings, system_waived = _partition(_rollup(findings), loaded, now)
|
|
71
|
+
waived.extend(system_waived)
|
|
72
|
+
warnings.extend(_expiry_warnings(waived, now))
|
|
73
|
+
|
|
74
|
+
citation_ids: set[str] = set()
|
|
75
|
+
for finding in findings + waived:
|
|
76
|
+
citation_ids.update(finding["citation_ids"])
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"schema_version": SCHEMA_VERSION,
|
|
80
|
+
"kind": "brief",
|
|
81
|
+
"tool": {"name": "flyleaf", "version": __version__},
|
|
82
|
+
"disclaimer": DISCLAIMER,
|
|
83
|
+
"severity_note": severity_grades.SEVERITY_NOTE,
|
|
84
|
+
"citation_pack": pack_meta(),
|
|
85
|
+
"citations": payloads_for(citation_ids),
|
|
86
|
+
"repository": str(repo),
|
|
87
|
+
"as_of": now.isoformat(),
|
|
88
|
+
"base": base_meta,
|
|
89
|
+
"head": head_meta,
|
|
90
|
+
"systems": head_inventory.get("systems", []),
|
|
91
|
+
"findings": findings,
|
|
92
|
+
"waived": waived,
|
|
93
|
+
"warnings": warnings,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _attribute(
|
|
98
|
+
repo: Path,
|
|
99
|
+
findings: list[dict],
|
|
100
|
+
head_inventory: dict,
|
|
101
|
+
head_meta: dict,
|
|
102
|
+
base_meta: dict,
|
|
103
|
+
blame: bool,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Put a name on every finding, from the declared owner and from git blame."""
|
|
106
|
+
owners = {
|
|
107
|
+
system["name"]: system.get("owner")
|
|
108
|
+
for system in head_inventory.get("systems") or []
|
|
109
|
+
}
|
|
110
|
+
head_rev = None if head_meta["ref"] == "working tree" else head_meta["rev"]
|
|
111
|
+
for finding in findings:
|
|
112
|
+
finding["owner"] = owners.get(finding.get("system") or "")
|
|
113
|
+
if not blame:
|
|
114
|
+
finding["author"] = None
|
|
115
|
+
continue
|
|
116
|
+
# A removed component is not in the head tree, so blame the base.
|
|
117
|
+
rev = base_meta.get("rev") if finding["change"] == "removed" else head_rev
|
|
118
|
+
finding["author"] = attribution.last_author(repo, rev, finding["path"], finding["line"])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def highest_severity(brief: dict) -> str | None:
|
|
122
|
+
"""The worst severity among active findings, or None when the brief is quiet."""
|
|
123
|
+
worst: str | None = None
|
|
124
|
+
for finding in brief["findings"]:
|
|
125
|
+
if worst is None or severity_grades.at_least(finding["severity"], worst):
|
|
126
|
+
worst = finding["severity"]
|
|
127
|
+
return worst
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _partition(
|
|
131
|
+
findings: list[dict],
|
|
132
|
+
waivers: list[waiver_store.Waiver],
|
|
133
|
+
today: date,
|
|
134
|
+
) -> tuple[list[dict], list[dict]]:
|
|
135
|
+
"""Split findings into active and waived.
|
|
136
|
+
|
|
137
|
+
An expired waiver does not suppress. The finding returns for review and
|
|
138
|
+
says which waiver lapsed.
|
|
139
|
+
"""
|
|
140
|
+
active: list[dict] = []
|
|
141
|
+
waived: list[dict] = []
|
|
142
|
+
for finding in findings:
|
|
143
|
+
waiver = waiver_store.match(waivers, finding["component_id"], finding["change"])
|
|
144
|
+
if waiver is None:
|
|
145
|
+
active.append(finding)
|
|
146
|
+
continue
|
|
147
|
+
payload = waiver.payload(today)
|
|
148
|
+
if waiver.days_left(today) < 0:
|
|
149
|
+
finding["waiver"] = payload
|
|
150
|
+
finding["waiver_state"] = "expired"
|
|
151
|
+
finding["summary"] = (
|
|
152
|
+
f"{finding['summary']} A waiver by {waiver.approved_by} expired on "
|
|
153
|
+
f"{waiver.expires.isoformat()}, so this is open again."
|
|
154
|
+
)
|
|
155
|
+
active.append(finding)
|
|
156
|
+
else:
|
|
157
|
+
finding["waiver"] = payload
|
|
158
|
+
finding["waiver_state"] = "active"
|
|
159
|
+
waived.append(finding)
|
|
160
|
+
return active, waived
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _expiry_warnings(waived: list[dict], today: date) -> list[str]:
|
|
164
|
+
warnings: list[str] = []
|
|
165
|
+
for finding in waived:
|
|
166
|
+
waiver = finding["waiver"]
|
|
167
|
+
days = waiver["days_left"]
|
|
168
|
+
if days <= waiver_store.EXPIRY_WARNING_DAYS:
|
|
169
|
+
warnings.append(
|
|
170
|
+
f"Waiver for {finding['component_id']} expires on {waiver['expires']} "
|
|
171
|
+
f"in {days} day(s). Renew it or close the finding."
|
|
172
|
+
)
|
|
173
|
+
return warnings
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _diff(base_inventory: dict, head_inventory: dict) -> list[dict]:
|
|
177
|
+
base_map = {component["id"]: component for component in base_inventory["components"]}
|
|
178
|
+
head_map = {component["id"]: component for component in head_inventory["components"]}
|
|
179
|
+
findings: list[dict] = []
|
|
180
|
+
for component_id in sorted(set(base_map) | set(head_map)):
|
|
181
|
+
before = base_map.get(component_id)
|
|
182
|
+
after = head_map.get(component_id)
|
|
183
|
+
if before is None and after is not None:
|
|
184
|
+
findings.append(_added(after))
|
|
185
|
+
elif after is None and before is not None:
|
|
186
|
+
findings.append(_removed(before))
|
|
187
|
+
elif before is not None and after is not None:
|
|
188
|
+
findings.extend(_changed(before, after))
|
|
189
|
+
return _sorted(findings)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _sorted(findings: list[dict]) -> list[dict]:
|
|
193
|
+
return sorted(
|
|
194
|
+
findings,
|
|
195
|
+
key=lambda item: (-severity_grades.rank(item["severity"]), item["path"], item["framework"]),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _rollup(findings: list[dict]) -> list[dict]:
|
|
200
|
+
"""Collapse the findings of a declared system into one.
|
|
201
|
+
|
|
202
|
+
Components are the evidence. A system is what a person documents, so when
|
|
203
|
+
the repository declares one, the system is what the brief reports.
|
|
204
|
+
"""
|
|
205
|
+
grouped: dict[str, list[dict]] = {}
|
|
206
|
+
standalone: list[dict] = []
|
|
207
|
+
for finding in findings:
|
|
208
|
+
name = finding.get("system")
|
|
209
|
+
if name:
|
|
210
|
+
grouped.setdefault(name, []).append(finding)
|
|
211
|
+
else:
|
|
212
|
+
standalone.append(finding)
|
|
213
|
+
rolled = [_system_finding(name, members) for name, members in grouped.items()]
|
|
214
|
+
return _sorted(standalone + rolled)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _system_finding(name: str, members: list[dict]) -> dict:
|
|
218
|
+
lead = max(members, key=lambda item: severity_grades.rank(item["severity"]))
|
|
219
|
+
citation_ids: list[str] = []
|
|
220
|
+
for member in members:
|
|
221
|
+
for citation_id in member["citation_ids"]:
|
|
222
|
+
if citation_id not in citation_ids:
|
|
223
|
+
citation_ids.append(citation_id)
|
|
224
|
+
frameworks = sorted({member["framework"] for member in members})
|
|
225
|
+
changes = sorted({member["change"] for member in members})
|
|
226
|
+
authors: list[dict] = []
|
|
227
|
+
for member in members:
|
|
228
|
+
author = member.get("author")
|
|
229
|
+
if author and author not in authors:
|
|
230
|
+
authors.append(author)
|
|
231
|
+
missing = any(member["model_card_status"] == "missing" for member in members)
|
|
232
|
+
if len(members) == 1:
|
|
233
|
+
summary = f"System '{name}': {lead['summary']}"
|
|
234
|
+
else:
|
|
235
|
+
summary = (
|
|
236
|
+
f"System '{name}': {len(members)} components changed "
|
|
237
|
+
f"({', '.join(changes)}). {lead['summary']}"
|
|
238
|
+
)
|
|
239
|
+
return {
|
|
240
|
+
"kind": "system",
|
|
241
|
+
"component_id": f"system:{name}",
|
|
242
|
+
"system": name,
|
|
243
|
+
"path": lead["path"],
|
|
244
|
+
"line": lead["line"],
|
|
245
|
+
"framework": ", ".join(frameworks),
|
|
246
|
+
"frameworks": frameworks,
|
|
247
|
+
"change": lead["change"],
|
|
248
|
+
"changes": changes,
|
|
249
|
+
"impact": "runtime"
|
|
250
|
+
if any(member["impact"] == "runtime" for member in members)
|
|
251
|
+
else "documentation",
|
|
252
|
+
"severity": lead["severity"],
|
|
253
|
+
"status": "missing" if missing else "needs_review",
|
|
254
|
+
"summary": summary,
|
|
255
|
+
"model_card_status": "missing" if missing else "present",
|
|
256
|
+
"citation_ids": citation_ids,
|
|
257
|
+
"owner": lead.get("owner"),
|
|
258
|
+
"author": lead.get("author"),
|
|
259
|
+
"authors": authors,
|
|
260
|
+
"members": [_member(member) for member in members],
|
|
261
|
+
"waiver": None,
|
|
262
|
+
"waiver_state": "none",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _member(finding: dict) -> dict:
|
|
267
|
+
return {
|
|
268
|
+
"component_id": finding["component_id"],
|
|
269
|
+
"path": finding["path"],
|
|
270
|
+
"line": finding["line"],
|
|
271
|
+
"framework": finding["framework"],
|
|
272
|
+
"change": finding["change"],
|
|
273
|
+
"impact": finding["impact"],
|
|
274
|
+
"severity": finding["severity"],
|
|
275
|
+
"author": finding.get("author"),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _added(component: dict) -> dict:
|
|
280
|
+
missing = component["model_card_status"] == "missing"
|
|
281
|
+
summary = (
|
|
282
|
+
"New component, and no model card was found."
|
|
283
|
+
if missing
|
|
284
|
+
else "New component. A model card is present. Confirm that the card describes this component."
|
|
285
|
+
)
|
|
286
|
+
return _finding(
|
|
287
|
+
component,
|
|
288
|
+
change="added",
|
|
289
|
+
status="missing" if missing else "needs_review",
|
|
290
|
+
summary=summary,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _removed(component: dict) -> dict:
|
|
295
|
+
return _finding(
|
|
296
|
+
component,
|
|
297
|
+
change="removed",
|
|
298
|
+
status="needs_review",
|
|
299
|
+
summary=(
|
|
300
|
+
"This component was in the base state and is absent from the head state. "
|
|
301
|
+
"Check whether a model card still describes it."
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _changed(before: dict, after: dict) -> list[dict]:
|
|
307
|
+
evidence_changed = _evidence_key(before) != _evidence_key(after)
|
|
308
|
+
card_changed = before.get("model_card_sha256") != after.get("model_card_sha256")
|
|
309
|
+
missing = after["model_card_status"] == "missing"
|
|
310
|
+
if not evidence_changed and not card_changed:
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
if missing and before["model_card_status"] == "present":
|
|
314
|
+
change = "card_removed"
|
|
315
|
+
summary = "The model card that was present in the base state is gone."
|
|
316
|
+
status = "missing"
|
|
317
|
+
elif before["model_card_status"] == "missing" and not missing:
|
|
318
|
+
change = "card_added"
|
|
319
|
+
summary = (
|
|
320
|
+
"A model card appeared for this component. "
|
|
321
|
+
"Confirm that it describes this component and not only its neighbours."
|
|
322
|
+
)
|
|
323
|
+
status = "needs_review"
|
|
324
|
+
elif evidence_changed and missing:
|
|
325
|
+
change = "evidence_changed"
|
|
326
|
+
summary = "The detected evidence changed, and there is still no model card."
|
|
327
|
+
status = "missing"
|
|
328
|
+
elif evidence_changed and not card_changed:
|
|
329
|
+
change = "card_stale"
|
|
330
|
+
summary = (
|
|
331
|
+
"The detected evidence changed and the model card content did not. "
|
|
332
|
+
"Article 11 asks that technical documentation of a high-risk system be kept up to date. "
|
|
333
|
+
"Confirm whether that article applies, and whether this card still matches the code."
|
|
334
|
+
)
|
|
335
|
+
status = "needs_review"
|
|
336
|
+
elif evidence_changed and card_changed:
|
|
337
|
+
change = "evidence_and_card_changed"
|
|
338
|
+
summary = "The detected evidence and the model card both changed. Read them together."
|
|
339
|
+
status = "needs_review"
|
|
340
|
+
else:
|
|
341
|
+
change = "card_changed"
|
|
342
|
+
summary = "The model card changed and the detected evidence did not."
|
|
343
|
+
status = "needs_review"
|
|
344
|
+
return [_finding(after, change=change, status=status, summary=summary)]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _finding(component: dict, change: str, status: str, summary: str) -> dict:
|
|
348
|
+
citation_ids: list[str] = []
|
|
349
|
+
for hint in component["review_hints"]:
|
|
350
|
+
for citation_id in hint["citation_ids"]:
|
|
351
|
+
if citation_id not in citation_ids:
|
|
352
|
+
citation_ids.append(citation_id)
|
|
353
|
+
for citation_id in component["documentation_citation_ids"]:
|
|
354
|
+
if citation_id not in citation_ids:
|
|
355
|
+
citation_ids.append(citation_id)
|
|
356
|
+
documented = component["model_card_status"] == "present"
|
|
357
|
+
impact, level = severity_grades.grade(change, documented)
|
|
358
|
+
evidence = component["evidence"]
|
|
359
|
+
return {
|
|
360
|
+
"kind": "component",
|
|
361
|
+
"component_id": component["id"],
|
|
362
|
+
"path": component["path"],
|
|
363
|
+
"line": evidence[0]["line"] if evidence else 1,
|
|
364
|
+
"framework": component["framework"],
|
|
365
|
+
"system": component.get("system"),
|
|
366
|
+
"change": change,
|
|
367
|
+
"impact": impact,
|
|
368
|
+
"severity": level,
|
|
369
|
+
"status": status,
|
|
370
|
+
"summary": summary,
|
|
371
|
+
"model_card_status": component["model_card_status"],
|
|
372
|
+
"citation_ids": citation_ids,
|
|
373
|
+
"owner": None,
|
|
374
|
+
"author": None,
|
|
375
|
+
"waiver": None,
|
|
376
|
+
"waiver_state": "none",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _evidence_key(component: dict) -> tuple[tuple[str, str], ...]:
|
|
381
|
+
return tuple((item["kind"], item["text"]) for item in component["evidence"])
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _scan_ref(repo: Path, rev: str) -> dict:
|
|
385
|
+
raw = _git_bytes(repo, "archive", rev)
|
|
386
|
+
temp = Path(tempfile.mkdtemp(prefix="flyleaf-"))
|
|
387
|
+
try:
|
|
388
|
+
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as archive:
|
|
389
|
+
kwargs = {"filter": "data"} if sys.version_info >= (3, 12) else {}
|
|
390
|
+
archive.extractall(temp, **kwargs)
|
|
391
|
+
return scan_path(temp)
|
|
392
|
+
finally:
|
|
393
|
+
shutil.rmtree(temp, ignore_errors=True)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _git_root(start: Path) -> Path:
|
|
397
|
+
return Path(_git(start, "rev-parse", "--show-toplevel"))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _rev_parse(repo: Path, ref: str) -> str:
|
|
401
|
+
return _git(repo, "rev-parse", "--verify", ref)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _git(repo: Path, *args: str) -> str:
|
|
405
|
+
return _run(repo, args, text=True).strip()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _git_bytes(repo: Path, *args: str) -> bytes:
|
|
409
|
+
return _run(repo, args, text=False)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _run(repo: Path, args: tuple[str, ...], text: bool):
|
|
413
|
+
try:
|
|
414
|
+
completed = subprocess.run(
|
|
415
|
+
["git", "-C", str(repo), *args],
|
|
416
|
+
check=True,
|
|
417
|
+
capture_output=True,
|
|
418
|
+
text=text,
|
|
419
|
+
)
|
|
420
|
+
except FileNotFoundError as exc:
|
|
421
|
+
raise GitError("git is not installed.") from exc
|
|
422
|
+
except subprocess.CalledProcessError as exc:
|
|
423
|
+
message = exc.stderr or exc.stdout or "git failed"
|
|
424
|
+
if isinstance(message, bytes):
|
|
425
|
+
message = message.decode(errors="replace")
|
|
426
|
+
raise GitError(str(message).strip()) from exc
|
|
427
|
+
return completed.stdout
|