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/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 + "/")