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/card.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Write model-card scaffolds. A person completes the blanks."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from flyleaf.citations import citation_payload
|
|
9
|
+
from flyleaf.scan import scan_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_cards(path: Path, output: Path, force: bool = False) -> list[Path]:
|
|
13
|
+
"""Write one scaffold per declared system, then one per loose component.
|
|
14
|
+
|
|
15
|
+
Existing files are left in place unless `force` is set. A scaffold is
|
|
16
|
+
written under `output`, never at a declared card path, so an unfinished
|
|
17
|
+
draft is never counted as documentation.
|
|
18
|
+
"""
|
|
19
|
+
inventory = scan_path(path)
|
|
20
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
written: list[Path] = []
|
|
22
|
+
|
|
23
|
+
for system in inventory.get("systems") or []:
|
|
24
|
+
members = [
|
|
25
|
+
component
|
|
26
|
+
for component in inventory["components"]
|
|
27
|
+
if component["system"] == system["name"]
|
|
28
|
+
]
|
|
29
|
+
if not members:
|
|
30
|
+
continue
|
|
31
|
+
destination = output / f"{_slug(system['name'])}.md"
|
|
32
|
+
if destination.exists() and not force:
|
|
33
|
+
continue
|
|
34
|
+
destination.write_text(_render_system(system, members, inventory), encoding="utf-8")
|
|
35
|
+
written.append(destination)
|
|
36
|
+
|
|
37
|
+
for component in inventory["components"]:
|
|
38
|
+
if component["system"]:
|
|
39
|
+
continue
|
|
40
|
+
destination = output / _filename(component["id"])
|
|
41
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
if destination.exists() and not force:
|
|
43
|
+
continue
|
|
44
|
+
destination.write_text(_render(component, inventory), encoding="utf-8")
|
|
45
|
+
written.append(destination)
|
|
46
|
+
return written
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _filename(component_id: str) -> Path:
|
|
50
|
+
return Path(component_id.replace(":", ".") + ".md")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _slug(name: str) -> str:
|
|
54
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-") or "system"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _render(component: dict, inventory: dict) -> str:
|
|
58
|
+
evidence = "\n".join(_evidence_lines(component))
|
|
59
|
+
citation_ids = _citation_ids([component])
|
|
60
|
+
return _page(
|
|
61
|
+
identity=[f"Component: `{component['id']}`"],
|
|
62
|
+
model_section=f"Framework: {component['framework']}\n\nEvidence:\n\n{evidence}",
|
|
63
|
+
hints="\n".join(f"- {hint['text']}" for hint in component["review_hints"]),
|
|
64
|
+
sources="\n\n".join(_source(inventory, item) for item in citation_ids),
|
|
65
|
+
pack=inventory["citation_pack"]["version"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render_system(system: dict, members: list[dict], inventory: dict) -> str:
|
|
70
|
+
identity = [f"System: `{system['name']}`"]
|
|
71
|
+
if system["owner"]:
|
|
72
|
+
identity.append(f"Owner: {system['owner']}")
|
|
73
|
+
if system["card"]:
|
|
74
|
+
identity.append(f"Declared card path: `{system['card']}`")
|
|
75
|
+
identity.append(
|
|
76
|
+
"This system is declared in `.flyleaf/systems.toml`. The components below "
|
|
77
|
+
"are the evidence flyleaf found in it. One system, one card."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
blocks = [f"Frameworks: {', '.join(system['frameworks'])}", "", "Components:", ""]
|
|
81
|
+
for component in members:
|
|
82
|
+
blocks.append(f"### `{component['path']}` ({component['framework']})")
|
|
83
|
+
blocks.append("")
|
|
84
|
+
blocks.extend(_evidence_lines(component))
|
|
85
|
+
blocks.append("")
|
|
86
|
+
hints: list[str] = []
|
|
87
|
+
for component in members:
|
|
88
|
+
for hint in component["review_hints"]:
|
|
89
|
+
line = f"- {hint['text']}"
|
|
90
|
+
if line not in hints:
|
|
91
|
+
hints.append(line)
|
|
92
|
+
return _page(
|
|
93
|
+
identity=identity,
|
|
94
|
+
model_section="\n".join(blocks).rstrip(),
|
|
95
|
+
hints="\n".join(hints),
|
|
96
|
+
sources="\n\n".join(_source(inventory, item) for item in _citation_ids(members)),
|
|
97
|
+
pack=inventory["citation_pack"]["version"],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _evidence_lines(component: dict) -> list[str]:
|
|
102
|
+
return [
|
|
103
|
+
f"- line {item['line']}: `{item['text']}` ({item['kind']})"
|
|
104
|
+
for item in component["evidence"]
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _citation_ids(components: list[dict]) -> list[str]:
|
|
109
|
+
citation_ids: list[str] = []
|
|
110
|
+
for component in components:
|
|
111
|
+
for citation_id in component["documentation_citation_ids"]:
|
|
112
|
+
if citation_id not in citation_ids:
|
|
113
|
+
citation_ids.append(citation_id)
|
|
114
|
+
for component in components:
|
|
115
|
+
for hint in component["review_hints"]:
|
|
116
|
+
for citation_id in hint["citation_ids"]:
|
|
117
|
+
if citation_id not in citation_ids:
|
|
118
|
+
citation_ids.append(citation_id)
|
|
119
|
+
return citation_ids
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _page(identity: list[str], model_section: str, hints: str, sources: str, pack: str) -> str:
|
|
123
|
+
header = "\n\n".join(identity)
|
|
124
|
+
return (
|
|
125
|
+
f"# Model card\n\n"
|
|
126
|
+
f"{header}\n\n"
|
|
127
|
+
f"Citation pack: {pack}\n\n"
|
|
128
|
+
f"flyleaf prepared this page. A person completes it. "
|
|
129
|
+
f"The scaffold does not decide the use case, the legal role, or whether a duty applies.\n\n"
|
|
130
|
+
f"## Purpose\n\n"
|
|
131
|
+
f"What this system is for:\n\n"
|
|
132
|
+
f"## Role\n\n"
|
|
133
|
+
f"Who provides the model, and who deploys the system. Read Article 3(3) and Article 3(4).\n\n"
|
|
134
|
+
f"Provider or deployer:\n\n"
|
|
135
|
+
f"## Data\n\n"
|
|
136
|
+
f"Training data, prompts, and personal data involved:\n\n"
|
|
137
|
+
f"## Model\n\n"
|
|
138
|
+
f"{model_section}\n\n"
|
|
139
|
+
f"## Limitations\n\n"
|
|
140
|
+
f"Known limits and failure modes:\n\n"
|
|
141
|
+
f"## Human oversight\n\n"
|
|
142
|
+
f"Who reviews outputs before they affect a person:\n\n"
|
|
143
|
+
f"## Use case\n\n"
|
|
144
|
+
f"Annex III category, if any. Leave this blank when none applies. "
|
|
145
|
+
f"The library name does not answer it.\n\n"
|
|
146
|
+
f"## Areas the scan asked a person to review\n\n"
|
|
147
|
+
f"{hints}\n\n"
|
|
148
|
+
f"## Sources\n\n"
|
|
149
|
+
f"{sources}\n"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _source(inventory: dict, citation_id: str) -> str:
|
|
154
|
+
citation = inventory["citations"].get(citation_id) or citation_payload(citation_id)
|
|
155
|
+
return (
|
|
156
|
+
f"### {citation['pinpoint']}\n\n"
|
|
157
|
+
f"{citation['instrument']} ({citation['celex']}), status {citation['status']}.\n\n"
|
|
158
|
+
f"> {citation['quote']}\n\n"
|
|
159
|
+
f"{citation['note']}\n\n"
|
|
160
|
+
f"{citation['source_url']}"
|
|
161
|
+
)
|
flyleaf/citations.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Versioned citations for review findings.
|
|
4
|
+
|
|
5
|
+
Edit this module when the Official Journal changes a provision the tool cites.
|
|
6
|
+
Bump PACK_VERSION and add a CHANGELOG entry in the same change. Reports copy
|
|
7
|
+
the version so an older run still names the text it used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
PACK_VERSION = "2026.07.27"
|
|
13
|
+
AS_OF = "2026-07-27"
|
|
14
|
+
|
|
15
|
+
_AI_ACT_URL = "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689"
|
|
16
|
+
_AMENDMENT_URL = "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32026R1744"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Citation:
|
|
21
|
+
id: str
|
|
22
|
+
instrument: str
|
|
23
|
+
celex: str
|
|
24
|
+
pinpoint: str
|
|
25
|
+
quote: str
|
|
26
|
+
note: str
|
|
27
|
+
source_url: str
|
|
28
|
+
status: str = "current"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CITATIONS: dict[str, Citation] = {
|
|
32
|
+
"art-3-provider": Citation(
|
|
33
|
+
id="art-3-provider",
|
|
34
|
+
instrument="Regulation (EU) 2024/1689",
|
|
35
|
+
celex="32024R1689",
|
|
36
|
+
pinpoint="Article 3(3)",
|
|
37
|
+
quote=(
|
|
38
|
+
"provider means a natural or legal person, public authority, agency or other body "
|
|
39
|
+
"that develops an AI system or a general-purpose AI model or that has an AI system "
|
|
40
|
+
"or a general-purpose AI model developed and places it on the market or puts the AI "
|
|
41
|
+
"system into service under its own name or trademark, whether for payment or free of charge"
|
|
42
|
+
),
|
|
43
|
+
note=(
|
|
44
|
+
"Use this definition to decide whether you provide the model. "
|
|
45
|
+
"An import of a hosted API does not settle that question."
|
|
46
|
+
),
|
|
47
|
+
source_url=_AI_ACT_URL,
|
|
48
|
+
),
|
|
49
|
+
"art-3-deployer": Citation(
|
|
50
|
+
id="art-3-deployer",
|
|
51
|
+
instrument="Regulation (EU) 2024/1689",
|
|
52
|
+
celex="32024R1689",
|
|
53
|
+
pinpoint="Article 3(4)",
|
|
54
|
+
quote=(
|
|
55
|
+
"deployer means a natural or legal person, public authority, agency or other body "
|
|
56
|
+
"using an AI system under its authority except where the AI system is used in the "
|
|
57
|
+
"course of a personal non-professional activity"
|
|
58
|
+
),
|
|
59
|
+
note=(
|
|
60
|
+
"Use this definition to decide whether you deploy someone else's system. "
|
|
61
|
+
"Points (3) and (4) of Article 3 were not the points amended by Regulation (EU) 2026/1744."
|
|
62
|
+
),
|
|
63
|
+
source_url=_AI_ACT_URL,
|
|
64
|
+
),
|
|
65
|
+
"art-6-annex-iii": Citation(
|
|
66
|
+
id="art-6-annex-iii",
|
|
67
|
+
instrument="Regulation (EU) 2024/1689",
|
|
68
|
+
celex="32024R1689",
|
|
69
|
+
pinpoint="Article 6(2) and Annex III",
|
|
70
|
+
quote=(
|
|
71
|
+
"In addition to the high-risk AI systems referred to in paragraph 1, "
|
|
72
|
+
"AI systems referred to in Annex III shall be considered to be high-risk."
|
|
73
|
+
),
|
|
74
|
+
note=(
|
|
75
|
+
"Annex III is a list of use cases. A library name is not one of those use cases. "
|
|
76
|
+
"Fill in the purpose yourself, then read Article 6(3) for the derogation. "
|
|
77
|
+
"Regulation (EU) 2026/1744 inserted Article 6(1a) to (1c) on safety components. "
|
|
78
|
+
"It left this Article 6(2) sentence in place."
|
|
79
|
+
),
|
|
80
|
+
source_url=_AI_ACT_URL,
|
|
81
|
+
),
|
|
82
|
+
"art-11-annex-iv": Citation(
|
|
83
|
+
id="art-11-annex-iv",
|
|
84
|
+
instrument="Regulation (EU) 2024/1689",
|
|
85
|
+
celex="32024R1689",
|
|
86
|
+
pinpoint="Article 11(1) and Annex IV",
|
|
87
|
+
quote=(
|
|
88
|
+
"The technical documentation of a high-risk AI system shall be drawn up before that "
|
|
89
|
+
"system is placed on the market or put into service and shall be kept up to date."
|
|
90
|
+
),
|
|
91
|
+
note=(
|
|
92
|
+
"This duty applies to high-risk systems. The scan cannot tell whether this component is one. "
|
|
93
|
+
"Regulation (EU) 2026/1744 replaced the second subparagraph of Article 11(1): "
|
|
94
|
+
"SMEs, including start-ups, and small mid-caps may supply the Annex IV elements "
|
|
95
|
+
"in a simplified form once the Commission establishes that form. "
|
|
96
|
+
"A missing or stale model card is a gap in the repository. "
|
|
97
|
+
"It is not a finding that Article 11 has been breached."
|
|
98
|
+
),
|
|
99
|
+
source_url=_AI_ACT_URL,
|
|
100
|
+
),
|
|
101
|
+
"art-50-interaction": Citation(
|
|
102
|
+
id="art-50-interaction",
|
|
103
|
+
instrument="Regulation (EU) 2024/1689",
|
|
104
|
+
celex="32024R1689",
|
|
105
|
+
pinpoint="Article 50(1)",
|
|
106
|
+
quote=(
|
|
107
|
+
"Providers shall ensure that AI systems intended to interact directly with natural persons "
|
|
108
|
+
"are designed and developed in such a way that the natural persons concerned are informed "
|
|
109
|
+
"that they are interacting with an AI system, unless this is obvious from the point of view "
|
|
110
|
+
"of a natural person who is reasonably well-informed, observant and circumspect, taking into "
|
|
111
|
+
"account the circumstances and the context of use."
|
|
112
|
+
),
|
|
113
|
+
note=(
|
|
114
|
+
"Read this when people interact with generated output. "
|
|
115
|
+
"The scan does not see the interface, so it cannot tell whether the duty applies. "
|
|
116
|
+
"Regulation (EU) 2026/1744 replaced Article 50(7), which concerns codes of "
|
|
117
|
+
"practice for marking synthetic content."
|
|
118
|
+
),
|
|
119
|
+
source_url=_AI_ACT_URL,
|
|
120
|
+
),
|
|
121
|
+
"art-53-gpai": Citation(
|
|
122
|
+
id="art-53-gpai",
|
|
123
|
+
instrument="Regulation (EU) 2024/1689",
|
|
124
|
+
celex="32024R1689",
|
|
125
|
+
pinpoint="Article 53(1)",
|
|
126
|
+
quote=(
|
|
127
|
+
"Providers of general-purpose AI models shall: "
|
|
128
|
+
"(a) draw up and keep up to date the technical documentation of the model"
|
|
129
|
+
),
|
|
130
|
+
note=(
|
|
131
|
+
"Article 53 binds the provider of the general-purpose model. "
|
|
132
|
+
"Calling that provider's API does not, by itself, make the caller that provider."
|
|
133
|
+
),
|
|
134
|
+
source_url=_AI_ACT_URL,
|
|
135
|
+
),
|
|
136
|
+
"art-113-application": Citation(
|
|
137
|
+
id="art-113-application",
|
|
138
|
+
instrument="Regulation (EU) 2026/1744",
|
|
139
|
+
celex="32026R1744",
|
|
140
|
+
pinpoint="Article 113, third paragraph, point (c), of Regulation (EU) 2024/1689, as replaced",
|
|
141
|
+
quote=(
|
|
142
|
+
"Chapter III, Sections 1, 2, and 3, with the exception of Article 6(5), shall apply from: "
|
|
143
|
+
"(i) 2 December 2027 as regards AI systems classified as high-risk pursuant to Article 6(2) "
|
|
144
|
+
"and Annex III; and (ii) 2 August 2028 as regards AI systems classified as high-risk pursuant "
|
|
145
|
+
"to Article 6(1) and Annex I"
|
|
146
|
+
),
|
|
147
|
+
note=(
|
|
148
|
+
"Regulation (EU) 2026/1744 was published in the Official Journal on 24 July 2026 "
|
|
149
|
+
"and entered into force on 27 July 2026. It deferred these Chapter III dates. "
|
|
150
|
+
"The citation tells you when to read the high-risk duties. It does not classify the system."
|
|
151
|
+
),
|
|
152
|
+
source_url=_AMENDMENT_URL,
|
|
153
|
+
),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
DOCUMENTATION_CITATION_IDS = ("art-11-annex-iv", "art-113-application")
|
|
157
|
+
|
|
158
|
+
CHANGELOG: tuple[dict[str, str], ...] = (
|
|
159
|
+
{
|
|
160
|
+
"version": "2026.07.27",
|
|
161
|
+
"summary": (
|
|
162
|
+
"Initial pack. Regulation (EU) 2024/1689 as amended by Regulation (EU) 2026/1744, "
|
|
163
|
+
"in force 27 July 2026. Records the deferred Chapter III dates in Article 113."
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def pack_meta() -> dict[str, str]:
|
|
170
|
+
return {"version": PACK_VERSION, "as_of": AS_OF}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def citation_payload(citation_id: str) -> dict[str, str]:
|
|
174
|
+
citation = CITATIONS[citation_id]
|
|
175
|
+
return {
|
|
176
|
+
"id": citation.id,
|
|
177
|
+
"instrument": citation.instrument,
|
|
178
|
+
"celex": citation.celex,
|
|
179
|
+
"pinpoint": citation.pinpoint,
|
|
180
|
+
"quote": citation.quote,
|
|
181
|
+
"note": citation.note,
|
|
182
|
+
"source_url": citation.source_url,
|
|
183
|
+
"status": citation.status,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def payloads_for(citation_ids: set[str]) -> dict[str, dict[str, str]]:
|
|
188
|
+
return {citation_id: citation_payload(citation_id) for citation_id in sorted(citation_ids)}
|
flyleaf/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Copyright 2026 Krishna Dahale
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Command line interface."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from flyleaf import __version__
|
|
11
|
+
from flyleaf.baseline import BASELINE_FILE, BaselineError, write_baseline
|
|
12
|
+
from flyleaf.brief import GitError, build_brief, highest_severity
|
|
13
|
+
from flyleaf.card import write_cards
|
|
14
|
+
from flyleaf.report import render, render_citations
|
|
15
|
+
from flyleaf.scan import scan_path
|
|
16
|
+
from flyleaf.severity import ORDER, at_least
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
add_completion=False,
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
help=(
|
|
22
|
+
"Inventory AI components in a repository and list areas to review, with citations. "
|
|
23
|
+
"Not a legal risk classifier."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
app()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback(invoke_without_command=True)
|
|
33
|
+
def _root(
|
|
34
|
+
version: bool = typer.Option(
|
|
35
|
+
False,
|
|
36
|
+
"--version",
|
|
37
|
+
"-V",
|
|
38
|
+
help="Print the version and exit.",
|
|
39
|
+
is_eager=True,
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
if version:
|
|
43
|
+
typer.echo(__version__)
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def scan(
|
|
49
|
+
path: Path = typer.Argument(Path("."), exists=False, help="Repository, directory, or file to scan."),
|
|
50
|
+
output_format: str = typer.Option("json", "--format", "-f", help="json or markdown."),
|
|
51
|
+
output: Path | None = typer.Option(
|
|
52
|
+
None,
|
|
53
|
+
"--output",
|
|
54
|
+
"-o",
|
|
55
|
+
help="Write the report to this file. Prints to stdout when omitted.",
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Scan a tree and print an AI component inventory.
|
|
59
|
+
|
|
60
|
+
The command exits 0 when the scan finishes, including when it finds AI
|
|
61
|
+
libraries. It does not fail a build for using an AI library.
|
|
62
|
+
"""
|
|
63
|
+
_check_format(output_format, {"json", "markdown"})
|
|
64
|
+
try:
|
|
65
|
+
document = scan_path(path)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
typer.echo(f"Path not found: {path}", err=True)
|
|
68
|
+
raise typer.Exit(code=2) from None
|
|
69
|
+
_emit(render(document, output_format), output)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def brief(
|
|
74
|
+
base: str | None = typer.Argument(None, help="Git ref to compare from. Omit with --baseline."),
|
|
75
|
+
head: str | None = typer.Argument(None, help="Git ref to compare to. Defaults to the working tree."),
|
|
76
|
+
path: Path = typer.Option(Path("."), "--path", "-p", help="Repository to read."),
|
|
77
|
+
use_baseline: bool = typer.Option(
|
|
78
|
+
False,
|
|
79
|
+
"--baseline",
|
|
80
|
+
help=f"Compare against the approved state in {BASELINE_FILE.as_posix()} instead of a git ref.",
|
|
81
|
+
),
|
|
82
|
+
fail_on: str = typer.Option(
|
|
83
|
+
"none",
|
|
84
|
+
"--fail-on",
|
|
85
|
+
help="Exit 1 when an active finding reaches this severity: none, low, medium, or high.",
|
|
86
|
+
),
|
|
87
|
+
blame: bool = typer.Option(
|
|
88
|
+
True,
|
|
89
|
+
"--blame/--no-blame",
|
|
90
|
+
help="Name who last changed the evidence, read from local git blame.",
|
|
91
|
+
),
|
|
92
|
+
output_format: str = typer.Option("json", "--format", "-f", help="json, markdown, or sarif."),
|
|
93
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write the brief to this file."),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Compare two states and list documentation changes with citations.
|
|
96
|
+
|
|
97
|
+
Status values are missing and needs_review. Severity ranks engineering
|
|
98
|
+
attention, not legal risk. The brief does not declare a legal breach.
|
|
99
|
+
"""
|
|
100
|
+
_check_format(output_format, {"json", "markdown", "sarif"})
|
|
101
|
+
if fail_on not in {"none", *ORDER}:
|
|
102
|
+
typer.echo("Fail-on must be none, low, medium, or high.", err=True)
|
|
103
|
+
raise typer.Exit(code=2)
|
|
104
|
+
if base is None and not use_baseline:
|
|
105
|
+
typer.echo("Give a base ref, or pass --baseline.", err=True)
|
|
106
|
+
raise typer.Exit(code=2)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
document = build_brief(path, base, head, use_baseline=use_baseline, blame=blame)
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
typer.echo(f"Path not found: {path}", err=True)
|
|
112
|
+
raise typer.Exit(code=2) from None
|
|
113
|
+
except (GitError, BaselineError) as exc:
|
|
114
|
+
typer.echo(str(exc), err=True)
|
|
115
|
+
raise typer.Exit(code=2) from None
|
|
116
|
+
|
|
117
|
+
_emit(render(document, output_format), output)
|
|
118
|
+
for warning in document["warnings"]:
|
|
119
|
+
typer.echo(warning, err=True)
|
|
120
|
+
|
|
121
|
+
if fail_on == "none":
|
|
122
|
+
return
|
|
123
|
+
worst = highest_severity(document)
|
|
124
|
+
if worst is not None and at_least(worst, fail_on):
|
|
125
|
+
typer.echo(
|
|
126
|
+
f"Highest severity is {worst}, at or above the --fail-on threshold of {fail_on}.",
|
|
127
|
+
err=True,
|
|
128
|
+
)
|
|
129
|
+
raise typer.Exit(code=1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command(name="render")
|
|
133
|
+
def render_command(
|
|
134
|
+
document: Path = typer.Argument(..., help="A brief or inventory saved with --format json."),
|
|
135
|
+
output_format: str = typer.Option("markdown", "--format", "-f", help="json, markdown, or sarif."),
|
|
136
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write the report to this file."),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Re-render a saved report in another format.
|
|
139
|
+
|
|
140
|
+
No scan and no git, so one scan can produce every format a pipeline wants,
|
|
141
|
+
and an archived report can be read back without the repository.
|
|
142
|
+
"""
|
|
143
|
+
_check_format(output_format, {"json", "markdown", "sarif"})
|
|
144
|
+
try:
|
|
145
|
+
loaded = json.loads(document.read_text(encoding="utf-8"))
|
|
146
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
147
|
+
typer.echo(f"Could not read {document}: {exc}", err=True)
|
|
148
|
+
raise typer.Exit(code=2) from None
|
|
149
|
+
if not isinstance(loaded, dict) or loaded.get("kind") not in {"brief", "inventory"}:
|
|
150
|
+
typer.echo(f"{document} is not a flyleaf brief or inventory.", err=True)
|
|
151
|
+
raise typer.Exit(code=2)
|
|
152
|
+
try:
|
|
153
|
+
text = render(loaded, output_format)
|
|
154
|
+
except (ValueError, KeyError) as exc:
|
|
155
|
+
typer.echo(str(exc), err=True)
|
|
156
|
+
raise typer.Exit(code=2) from None
|
|
157
|
+
_emit(text, output)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command(name="baseline")
|
|
161
|
+
def baseline_command(
|
|
162
|
+
path: Path = typer.Option(Path("."), "--path", "-p", help="Repository to read."),
|
|
163
|
+
approved_by: str | None = typer.Option(
|
|
164
|
+
None, "--approved-by", help="Who signed off on this state."
|
|
165
|
+
),
|
|
166
|
+
rev: str | None = typer.Option(None, "--rev", help="Revision this snapshot represents."),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Record the current inventory as the approved state.
|
|
169
|
+
|
|
170
|
+
Later runs of 'flyleaf brief --baseline' report what changed since this sign-off.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
destination = write_baseline(path, rev, approved_by)
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
typer.echo(f"Path not found: {path}", err=True)
|
|
176
|
+
raise typer.Exit(code=2) from None
|
|
177
|
+
typer.echo(destination)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def card(
|
|
182
|
+
path: Path = typer.Argument(Path("."), exists=False, help="Repository, directory, or file to scan."),
|
|
183
|
+
output: Path = typer.Option(
|
|
184
|
+
Path("flyleaf-cards"),
|
|
185
|
+
"--output",
|
|
186
|
+
"-o",
|
|
187
|
+
help="Directory for the scaffolds. Existing files are kept unless --force is set.",
|
|
188
|
+
),
|
|
189
|
+
force: bool = typer.Option(False, "--force", help="Overwrite scaffolds that already exist."),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Write a model-card scaffold per declared system, then per loose component.
|
|
192
|
+
|
|
193
|
+
Scaffolds land under --output, never at a declared card path, so an
|
|
194
|
+
unfinished draft never counts as documentation.
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
written = write_cards(path, output, force=force)
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
typer.echo(f"Path not found: {path}", err=True)
|
|
200
|
+
raise typer.Exit(code=2) from None
|
|
201
|
+
if not written:
|
|
202
|
+
typer.echo(f"No new scaffolds written under {output}.")
|
|
203
|
+
return
|
|
204
|
+
for destination in written:
|
|
205
|
+
typer.echo(destination)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command(name="cite")
|
|
209
|
+
def cite_command(
|
|
210
|
+
citation_id: str | None = typer.Argument(None, help="Citation id. Omit to list the pack."),
|
|
211
|
+
changelog: bool = typer.Option(False, "--changelog", help="Print the pack changelog."),
|
|
212
|
+
output_format: str = typer.Option("markdown", "--format", "-f", help="markdown or json."),
|
|
213
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write the citation text to this file."),
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Print the citation pack, or one entry, with the source URL and pack version."""
|
|
216
|
+
_check_format(output_format, {"json", "markdown"})
|
|
217
|
+
try:
|
|
218
|
+
text = render_citations(citation_id, changelog, output_format)
|
|
219
|
+
except KeyError:
|
|
220
|
+
typer.echo(f"Unknown citation: {citation_id}", err=True)
|
|
221
|
+
raise typer.Exit(code=2) from None
|
|
222
|
+
_emit(text, output)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _check_format(output_format: str, allowed: set[str]) -> None:
|
|
226
|
+
if output_format not in allowed:
|
|
227
|
+
typer.echo(f"Format must be {' or '.join(sorted(allowed))}.", err=True)
|
|
228
|
+
raise typer.Exit(code=2)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _emit(text: str, output: Path | None) -> None:
|
|
232
|
+
if output is None:
|
|
233
|
+
typer.echo(text, nl=False)
|
|
234
|
+
return
|
|
235
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
output.write_text(text, encoding="utf-8")
|