ai-profile-cli 0.3.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.
- ai_profile_cli-0.3.0.dist-info/METADATA +215 -0
- ai_profile_cli-0.3.0.dist-info/RECORD +32 -0
- ai_profile_cli-0.3.0.dist-info/WHEEL +4 -0
- ai_profile_cli-0.3.0.dist-info/entry_points.txt +2 -0
- ai_profile_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- aiprofile/__init__.py +7 -0
- aiprofile/__main__.py +7 -0
- aiprofile/adapters/__init__.py +1 -0
- aiprofile/adapters/trailers.py +381 -0
- aiprofile/aggregate.py +338 -0
- aiprofile/cli.py +299 -0
- aiprofile/config.py +233 -0
- aiprofile/errors.py +33 -0
- aiprofile/export.py +201 -0
- aiprofile/gitio.py +326 -0
- aiprofile/privacy.py +256 -0
- aiprofile/registry.py +200 -0
- aiprofile/render/__init__.py +1 -0
- aiprofile/render/badge_svg.py +90 -0
- aiprofile/render/brand.py +514 -0
- aiprofile/render/heatmap_svg.py +369 -0
- aiprofile/render/summary_svg.py +1180 -0
- aiprofile/render/themes.py +76 -0
- aiprofile/scanner.py +203 -0
- aiprofile/schema/__init__.py +37 -0
- aiprofile/schema/event.py +528 -0
- aiprofile/schema/vocab.py +208 -0
- aiprofile/storage/__init__.py +1 -0
- aiprofile/storage/db.py +136 -0
- aiprofile/storage/migrations.py +76 -0
- aiprofile/storage/store.py +186 -0
- aiprofile/viz.py +535 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-profile-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Local-first, profile-level AI collaboration analytics for GitHub: aggregate explicit AI provenance across repositories into privacy-safe SVG/JSON README assets.
|
|
5
|
+
Project-URL: Homepage, https://github.com/WenyuChiou/ai-profile
|
|
6
|
+
Project-URL: Repository, https://github.com/WenyuChiou/ai-profile
|
|
7
|
+
Project-URL: Issues, https://github.com/WenyuChiou/ai-profile/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/WenyuChiou/ai-profile/blob/main/CHANGELOG.md
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,attribution,git,github-profile,provenance
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: hypothesis>=6; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
<picture>
|
|
26
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/banner-dark.svg">
|
|
27
|
+
<img alt="ai-profile - prove your AI collaboration, privately" src="docs/assets/banner-light.svg" width="100%">
|
|
28
|
+
</picture>
|
|
29
|
+
|
|
30
|
+
# ai-profile
|
|
31
|
+
|
|
32
|
+
[](https://github.com/WenyuChiou/ai-profile/actions/workflows/ci.yml)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](pyproject.toml)
|
|
35
|
+
|
|
36
|
+
English · [繁體中文](README.zh-TW.md)
|
|
37
|
+
|
|
38
|
+
Local-first, profile-level **AI collaboration analytics** for your GitHub
|
|
39
|
+
README. `aiprofile` scans your local Git repositories for *explicit* AI
|
|
40
|
+
provenance — `AI-*` commit trailers and known AI co-author trailers (Claude
|
|
41
|
+
Code, Codex, Cursor, Copilot, Aider, …) — normalizes it into a common event
|
|
42
|
+
schema (ACE), stores it in a local SQLite database, and renders privacy-safe
|
|
43
|
+
SVG cards + a JSON summary you can embed in a GitHub Profile README.
|
|
44
|
+
|
|
45
|
+
## What it looks like
|
|
46
|
+
|
|
47
|
+
<picture>
|
|
48
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/summary-sample-dark.svg">
|
|
49
|
+
<img alt="Sample AI collaboration summary card rendered from synthetic data" src="docs/assets/summary-sample-light.svg">
|
|
50
|
+
</picture>
|
|
51
|
+
|
|
52
|
+
<picture>
|
|
53
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/heatmap-sample-dark.svg">
|
|
54
|
+
<img alt="Sample collaboration heatmap: intensity is total commits including your own, hue is the day's AI share" src="docs/assets/heatmap-sample-light.svg">
|
|
55
|
+
</picture>
|
|
56
|
+
|
|
57
|
+
<picture>
|
|
58
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/badge-sample-dark.svg">
|
|
59
|
+
<img alt="AI-assisted share badge, verified from git provenance" src="docs/assets/badge-sample-light.svg">
|
|
60
|
+
</picture>
|
|
61
|
+
|
|
62
|
+
Samples rendered from a synthetic showcase fixture; no real repository
|
|
63
|
+
data. The heatmap is the view no other tool draws: intensity is your
|
|
64
|
+
whole commit rhythm (your own commits included), hue is how much of
|
|
65
|
+
each day was AI collaboration.
|
|
66
|
+
|
|
67
|
+
It is **not** an AI code detector: nothing is ever inferred from code style.
|
|
68
|
+
Commits without explicit evidence are honestly reported as `unknown` —
|
|
69
|
+
never silently counted as human, never guessed into a provider.
|
|
70
|
+
|
|
71
|
+
As far as we know it is the only free, local-first, cross-repository,
|
|
72
|
+
explicit-provenance profile aggregation tool (line-level attribution
|
|
73
|
+
belongs to tools like git-ai; full analysis:
|
|
74
|
+
[landscape & positioning](docs/landscape.md)).
|
|
75
|
+
|
|
76
|
+
Status: **v0.3** — the v0.1 vertical slice (one repo → trailers → SQLite →
|
|
77
|
+
aggregate → summary card) plus provider brand identity (15 marks, two
|
|
78
|
+
icon sources), the publishable-only isometric daily calendar, and the
|
|
79
|
+
collaboration-ratio heatmap + badge. Design docs live in
|
|
80
|
+
[`docs/`](docs/):
|
|
81
|
+
[architecture](docs/architecture.md) · [ACE schema](docs/schema.md) ·
|
|
82
|
+
[MVP boundary](docs/mvp.md) · [landscape & non-duplication](docs/landscape.md)
|
|
83
|
+
· [decision records](docs/decisions/).
|
|
84
|
+
|
|
85
|
+
## Install
|
|
86
|
+
|
|
87
|
+
Compatibility: Python 3.11–3.14 · git ≥ 2.17 · SHA-1 repositories
|
|
88
|
+
(SHA-256 object format fails with a clear error in v0.1) · Windows,
|
|
89
|
+
macOS, Linux. Zero runtime dependencies.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install git+https://github.com/WenyuChiou/ai-profile
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The PyPI package will be **`ai-profile-cli`** (upload in progress;
|
|
96
|
+
PyPI's name-similarity rule blocks `ai-profile` because an unrelated
|
|
97
|
+
project already holds `aiprofile` — and `pip install aiprofile` gets
|
|
98
|
+
you that other project, not this one). Once live:
|
|
99
|
+
`pip install ai-profile-cli`.
|
|
100
|
+
|
|
101
|
+
From a clone (development):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e ".[dev]" # dev extras = pytest + ruff + hypothesis
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Quickstart
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
aiprofile init # run from INSIDE one of your repos - seeds your
|
|
111
|
+
# identity from that repo's git config user.email
|
|
112
|
+
aiprofile scan ~/my/repo # register + scan (replace with a real path;
|
|
113
|
+
# private-safe default)
|
|
114
|
+
aiprofile aggregate # print the published stats = privacy preview
|
|
115
|
+
aiprofile render # write dist/: summary + heatmap + badge SVG
|
|
116
|
+
# pairs (light/dark) + profile.json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Run **one `render` at a time per output directory** — concurrent renders
|
|
120
|
+
into the same directory are unsupported and can publish assets from
|
|
121
|
+
different scans.
|
|
122
|
+
|
|
123
|
+
Only commits authored by your configured identities count (seeded from
|
|
124
|
+
`git config user.email` at init; add more emails in
|
|
125
|
+
`~/.aiprofile/config.json`).
|
|
126
|
+
|
|
127
|
+
Run `aiprofile init` **from inside one of your own repos**: identity
|
|
128
|
+
seeding reads `git config user.email` in the directory where you run
|
|
129
|
+
it, so running from an unrelated folder can seed your global email
|
|
130
|
+
instead of the one you actually commit with (or none at all). Check
|
|
131
|
+
the seeded identities in `~/.aiprofile/config.json` after init.
|
|
132
|
+
|
|
133
|
+
Embed in your profile README:
|
|
134
|
+
|
|
135
|
+
```html
|
|
136
|
+
<picture>
|
|
137
|
+
<source media="(prefers-color-scheme: dark)" srcset="dist/summary-dark.svg">
|
|
138
|
+
<img alt="AI collaboration summary" src="dist/summary-light.svg">
|
|
139
|
+
</picture>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Declaring AI participation (trailers)
|
|
143
|
+
|
|
144
|
+
**If you commit through Claude Code, Codex, Cursor, Copilot, Aider, or
|
|
145
|
+
Amp, you likely have nothing to do**: tools that add their own
|
|
146
|
+
co-author trailer (e.g. Claude Code's
|
|
147
|
+
`Co-Authored-By: Claude <noreply@anthropic.com>`) are recognized
|
|
148
|
+
automatically via a verified identity registry.
|
|
149
|
+
|
|
150
|
+
For everything else — or for richer detail (model, role, review
|
|
151
|
+
status) — declare explicitly with `AI-*` trailers; product names like
|
|
152
|
+
`Kimi`, `Claude`, or `Gemini` resolve too:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
feat: add aggregation service
|
|
156
|
+
|
|
157
|
+
AI-Provider: Anthropic
|
|
158
|
+
AI-Model: Claude-Sonnet
|
|
159
|
+
AI-Tool: Claude-Code
|
|
160
|
+
AI-Role: implementation, documentation
|
|
161
|
+
AI-Mode: AI-Assisted
|
|
162
|
+
AI-Reviewed-By: Human
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
One commit can carry
|
|
166
|
+
several **AI actor presences** ("Claude implements, Codex reviews" =
|
|
167
|
+
1 unique commit, 2 presences — the two metrics are never conflated; a
|
|
168
|
+
presence means "this provider/tool appeared in this commit", so Claude
|
|
169
|
+
implementing AND reviewing one commit counts once, honestly). A commit
|
|
170
|
+
that is explicitly yours alone: `AI-Mode: Human-Only`.
|
|
171
|
+
|
|
172
|
+
## Privacy model (defaults are safe)
|
|
173
|
+
|
|
174
|
+
- Everything stays on your machine; no network calls, no telemetry.
|
|
175
|
+
- Every scanned repository defaults to `aggregate_only`: it contributes
|
|
176
|
+
counts, never its name. `scan --full` is the explicit opt-in that marks
|
|
177
|
+
a repository's counts as explicitly publishable (a policy decision you
|
|
178
|
+
make — NOT a claim about GitHub visibility); `excluded` removes a repository entirely.
|
|
179
|
+
- Publication policy lives in `config.json` only — edit it and the next
|
|
180
|
+
`aggregate`/`render` respects it, no rescan needed.
|
|
181
|
+
- Public outputs contain counts, provider names, evidence totals, and a
|
|
182
|
+
UTC date. Never: repository names/paths, org names, branches, commit
|
|
183
|
+
SHAs or messages, raw trailer strings, emails, or timestamps finer than
|
|
184
|
+
a date. Unrecognized provider spellings are bucketed as "Unrecognized"
|
|
185
|
+
in public assets (see the raw values locally with `aggregate -v`).
|
|
186
|
+
- `aiprofile aggregate` prints exactly what would be published — it *is*
|
|
187
|
+
the privacy preview.
|
|
188
|
+
- Honest limit: aggregate-only publication is **identity redaction, not
|
|
189
|
+
anonymity**. Repository names never appear, but repeatedly published
|
|
190
|
+
exact counts let an observer infer *when* your aggregate-only activity
|
|
191
|
+
changed and which provider appeared. Full threat model:
|
|
192
|
+
[`docs/PRIVACY.md`](docs/PRIVACY.md). Output labels are policy-based
|
|
193
|
+
("explicitly publishable" / "aggregate-only"), never claims about
|
|
194
|
+
GitHub visibility.
|
|
195
|
+
- Do not sync `~/.aiprofile` into published dotfiles (it holds a salt and
|
|
196
|
+
private repository paths). Deleting that directory deletes all local
|
|
197
|
+
data; generated `dist/` files are yours to remove separately. On
|
|
198
|
+
POSIX the directory and files are owner-only (0700/0600); Windows
|
|
199
|
+
has no equivalent permission bits, so there `os.chmod` is a
|
|
200
|
+
documented no-op — the data still never leaves your machine.
|
|
201
|
+
|
|
202
|
+
## Metrics, honestly labeled
|
|
203
|
+
|
|
204
|
+
- **AI-attributed commits** — unique commits with ≥1 explicit AI actor
|
|
205
|
+
presence. Per-provider counts may sum to more than this (multi-AI
|
|
206
|
+
commits) and are labeled as provider-attributed commits, never as
|
|
207
|
+
unique totals. Evidence chips state their population; percentages state
|
|
208
|
+
their denominator; active days are commit author dates.
|
|
209
|
+
- **Evidence quality** is first-class: `verified > declared > imported >
|
|
210
|
+
inferred > unknown`. v0.1 produces `declared` (trailers) and `unknown`.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT — see [LICENSE](LICENSE). Contributions welcome under
|
|
215
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
aiprofile/__init__.py,sha256=PQdSKLq8i3j8kBTe1VaLW1cp6EGcfWz_m24jFCZh9Qg,263
|
|
2
|
+
aiprofile/__main__.py,sha256=vfaB5IsNzfve4aiagsrV2m0JgLsUPhWmoASdomBUJIg,92
|
|
3
|
+
aiprofile/aggregate.py,sha256=3ELSLHGJ7zrtlhFa6IKjLZSvM9QJaSsf1AiGgKnl3LM,15276
|
|
4
|
+
aiprofile/cli.py,sha256=cODSlPQPCulZIwFoFfrMgB7bU1TSJF5i8wI334QOILs,10820
|
|
5
|
+
aiprofile/config.py,sha256=5L7WSVLn1HlNI0_hZycLod4skbYevJTqhLO9PwgmUoQ,8930
|
|
6
|
+
aiprofile/errors.py,sha256=fvh6rvg4LQcvc1yCyl_OQeHIEezflJa4X52tDYZZqUI,905
|
|
7
|
+
aiprofile/export.py,sha256=fJlz4kIfUJyqp6CjJy0qohfyI-QgudLKBr8OQnskiwE,9175
|
|
8
|
+
aiprofile/gitio.py,sha256=ZWA0ee5TDnC0LtOm21Nrrc8WlEcTnTJnEZKTOUZyJqs,13743
|
|
9
|
+
aiprofile/privacy.py,sha256=U1Q6SVEB6TexoNojy2785nOKnhd8EySi_qaifEEz5Lk,9662
|
|
10
|
+
aiprofile/registry.py,sha256=Wz2HL32C9OtsGZFIqJR0ZJxrMxuuNQgSNdOrTQb019I,8126
|
|
11
|
+
aiprofile/scanner.py,sha256=nQVSCrJs8ecLIvaei3wYBFL_oplAIPD7wnfO5NoyL7Y,7930
|
|
12
|
+
aiprofile/viz.py,sha256=8_WPOZRZdAQ9R-6xmwCBvmSe-hwGQU1yBN0xpO--I9M,23033
|
|
13
|
+
aiprofile/adapters/__init__.py,sha256=wi-dha9F06tN8T5VWTiLlLQHEHKBd3ZL3f1TRirFLI8,72
|
|
14
|
+
aiprofile/adapters/trailers.py,sha256=md9FwhU8OmkyhSoYo9yoD4jKL8rOIS52dVQ-GCx85Yw,14856
|
|
15
|
+
aiprofile/render/__init__.py,sha256=N-u5nMqIglDmNbfXluwpu0MrxInD7bN4k30ihNPgnZ8,85
|
|
16
|
+
aiprofile/render/badge_svg.py,sha256=-xcmzlxIxBMHimnpIDOBk1jWtZI0KmwC-iwugf_kSaM,3945
|
|
17
|
+
aiprofile/render/brand.py,sha256=3--vrXbvbjHbLuS3VAvRMQzzq6ykNay63FZpLlZ556Y,31936
|
|
18
|
+
aiprofile/render/heatmap_svg.py,sha256=ox2kYhfEKwLeBOKWFmj92JBFIA1EXPgutadrrIbJ_tk,13908
|
|
19
|
+
aiprofile/render/summary_svg.py,sha256=txXbwFtSB6QZQy8Jvg4pNUjfW8EQfaYuJ2x6-qF3n1A,48360
|
|
20
|
+
aiprofile/render/themes.py,sha256=_gKtBEdoS5z-SMFD2TsHqysuFzboBaSJVjm58K9_r6U,2222
|
|
21
|
+
aiprofile/schema/__init__.py,sha256=rS15vj43L5HhKcdxQCBWt5cGBs92vtmgpssOPwwePhE,644
|
|
22
|
+
aiprofile/schema/event.py,sha256=KGM_vMFEStzdVE39Nyuy-sY513BGnevErnBE5J2b6cg,21026
|
|
23
|
+
aiprofile/schema/vocab.py,sha256=c6juMN4x_AOjH0Kl6JVyDREdQQFcg4d-HDUd7EZCeQ8,6394
|
|
24
|
+
aiprofile/storage/__init__.py,sha256=SVLUBpdIYNvyIE5f1HlvnYEFMOBuBuK_0UEntlrvCLM,72
|
|
25
|
+
aiprofile/storage/db.py,sha256=JFujTDhW4Q430jcAp7CAp9tn_eLtdcwfnyqfhfds6fE,5421
|
|
26
|
+
aiprofile/storage/migrations.py,sha256=TPgRIvbwPGZYrB_qhrNfDwHD4HVoec0IxHNU2zP5BQM,2501
|
|
27
|
+
aiprofile/storage/store.py,sha256=sj4CP43BEZEbpvB56tYfhzNjnCOgQ3akmIyD3l7Jaz0,6445
|
|
28
|
+
ai_profile_cli-0.3.0.dist-info/METADATA,sha256=ZYmlw4q4j2RBIzD6I41CooKAswQl72AFn4D_Q1yW-1w,9702
|
|
29
|
+
ai_profile_cli-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
30
|
+
ai_profile_cli-0.3.0.dist-info/entry_points.txt,sha256=LhYqGd80ct4ObueO1tsAQbxhYZUO2z7ef4u_bcKFbFw,49
|
|
31
|
+
ai_profile_cli-0.3.0.dist-info/licenses/LICENSE,sha256=HgNCKf1GuKd0i84QnjvMB44YC7BeJ25RMDniuamQxjc,1068
|
|
32
|
+
ai_profile_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wenyu Chiou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
aiprofile/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""ai-profile: local-first, profile-level AI collaboration analytics for Git repositories."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
#: ACE (AI Collaboration Event) schema version written into every event.
|
|
6
|
+
#: Governed by docs/schema.md and ADR-012.
|
|
7
|
+
ACE_SCHEMA_VERSION = "0.2.0"
|
aiprofile/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Source adapters: provider-specific records → ParticipationSpec."""
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Git trailer adapter: trailer lines → ParticipationSpec (ADR-005).
|
|
2
|
+
|
|
3
|
+
INTERFACE PINNED BY THE ORCHESTRATOR — work package B implements
|
|
4
|
+
parse_commit_trailers without changing any signature or dataclass here.
|
|
5
|
+
Normative behavior: ADR-005 (grouping rule + carved exceptions),
|
|
6
|
+
schema.md sections 5 and 10, ADR-013 (registry matching).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from ..registry import match_coauthor, normalize_provider, resolve_tool
|
|
16
|
+
from ..schema.event import ProvenanceSource
|
|
17
|
+
from ..schema.vocab import ActorType, ContributionMode, EvidenceLevel, Role, SourceType
|
|
18
|
+
|
|
19
|
+
#: Recognized AI-* trailer keys (case-insensitive; ADR-005).
|
|
20
|
+
AI_TRAILER_KEYS = frozenset(
|
|
21
|
+
{"ai-provider", "ai-model", "ai-tool", "ai-role", "ai-mode", "ai-reviewed-by", "ai-schema"}
|
|
22
|
+
)
|
|
23
|
+
COAUTHOR_KEY = "co-authored-by"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ParticipationSpec:
|
|
28
|
+
"""One AI actor presence (or human declaration) parsed from explicit
|
|
29
|
+
trailer evidence. The scanner turns specs into validated ACE events;
|
|
30
|
+
the adapter never invents data."""
|
|
31
|
+
|
|
32
|
+
actor_type: ActorType
|
|
33
|
+
provider: str | None
|
|
34
|
+
provider_raw: str | None
|
|
35
|
+
model: str | None # lowercase(trim(model_raw)) — no registry (schema.md 10)
|
|
36
|
+
model_raw: str | None
|
|
37
|
+
tool: str | None
|
|
38
|
+
tool_raw: str | None
|
|
39
|
+
roles: tuple[Role, ...]
|
|
40
|
+
contribution_mode: ContributionMode | None
|
|
41
|
+
human_reviewed: bool | None
|
|
42
|
+
source: ProvenanceSource
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ParseWarning:
|
|
47
|
+
"""Diagnostics-hygiene-safe warning (architecture.md section 10): `code`
|
|
48
|
+
and `trailer_key` are always safe to print; `local_detail` may quote
|
|
49
|
+
trailer values and is shown only under --verbose."""
|
|
50
|
+
|
|
51
|
+
code: str # unknown-role | malformed-mode | malformed-reviewed-by |
|
|
52
|
+
# incomplete-group | contradictory-group
|
|
53
|
+
trailer_key: str
|
|
54
|
+
local_detail: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_commit_trailers(
|
|
58
|
+
trailer_lines: Sequence[str],
|
|
59
|
+
) -> tuple[list[ParticipationSpec], list[ParseWarning]]:
|
|
60
|
+
"""Parse one commit's trailer block (ordered lines from
|
|
61
|
+
%(trailers:only,unfold)) into actor-presence specs.
|
|
62
|
+
|
|
63
|
+
Contract (implemented by work package B):
|
|
64
|
+
- Trailer lines split on the first ':'; keys matched case-insensitively;
|
|
65
|
+
non-trailer-shaped lines ignored.
|
|
66
|
+
- AI-* grouping: iterate AI-* trailers in order; a repeated key inside
|
|
67
|
+
the current group closes it and starts a new one (ADR-005).
|
|
68
|
+
- A group yields an AI spec when AI-Provider is present (registry
|
|
69
|
+
normalization; unrecognized → provider None + provider_raw kept) or
|
|
70
|
+
AI-Tool resolves via registry.resolve_tool (which supplies the
|
|
71
|
+
provider). Groups with neither are dropped: warning `incomplete-group`.
|
|
72
|
+
- `AI-Mode: Human-Only` in a group with NO AI-Provider/AI-Tool key →
|
|
73
|
+
one HUMAN spec (all identity fields None, evidence declared). With
|
|
74
|
+
any AI-Provider or AI-Tool key present (recognized by the registry
|
|
75
|
+
or not) → drop the whole group: warning `contradictory-group`.
|
|
76
|
+
- AI-Role: comma-separated tokens → Role vocabulary; unknown tokens
|
|
77
|
+
dropped with warning `unknown-role`; empty result = no roles.
|
|
78
|
+
- AI-Mode: vocabulary values matched case-insensitively with '-'/'_'
|
|
79
|
+
equivalence ("AI-Assisted" → ai_assisted); unparseable → None +
|
|
80
|
+
warning `malformed-mode`. Missing mode on an AI group → None (the
|
|
81
|
+
group itself declares AI; schema.md section 5).
|
|
82
|
+
- AI-Reviewed-By: "human" → True, "none" → False, else None + warning
|
|
83
|
+
`malformed-reviewed-by`.
|
|
84
|
+
- AI-Schema: informational only, never affects parsing.
|
|
85
|
+
- Co-authored-by: value shaped "Name <email>"; registry.match_coauthor
|
|
86
|
+
(exact email + optional name-prefix condition) → one AI spec with
|
|
87
|
+
source git_trailer_coauthor, model/model_raw None, provider_raw set
|
|
88
|
+
to the display name. Non-matching co-authors are ignored silently
|
|
89
|
+
(they are presumed human co-authors, not evidence of anything).
|
|
90
|
+
- Sources: git_trailer / git_trailer_coauthor, evidence declared,
|
|
91
|
+
source_reference = the matched trailer key (never a value).
|
|
92
|
+
- Never raises on message content; returns ([], []) for no evidence.
|
|
93
|
+
- Same provider+tool in several groups is fine — the scanner merges by
|
|
94
|
+
identity; the parser does not deduplicate.
|
|
95
|
+
"""
|
|
96
|
+
specs: list[ParticipationSpec] = []
|
|
97
|
+
warnings: list[ParseWarning] = []
|
|
98
|
+
|
|
99
|
+
# `pending` holds, in message order, either an in-progress AI-* group
|
|
100
|
+
# or an already-resolved co-author ParticipationSpec. Co-author lines
|
|
101
|
+
# never depend on later lines so they are resolved immediately; AI-*
|
|
102
|
+
# groups are finalized only after the loop so a group left open at
|
|
103
|
+
# end-of-input is handled identically to one closed early by a repeated
|
|
104
|
+
# key (ADR-005). A group tracks VALUES and KEY PRESENCE separately
|
|
105
|
+
# (gate M-03): an empty-valued `AI-Provider:` contributes no value but
|
|
106
|
+
# its presence still matters to the Human-Only contradiction rule.
|
|
107
|
+
pending: list[_Group | ParticipationSpec] = []
|
|
108
|
+
current_group: _Group | None = None
|
|
109
|
+
|
|
110
|
+
for line in trailer_lines:
|
|
111
|
+
key, value = _split_trailer_line(line)
|
|
112
|
+
if key is None:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if key == COAUTHOR_KEY:
|
|
116
|
+
if value:
|
|
117
|
+
coauthor_spec = _parse_coauthor(value)
|
|
118
|
+
if coauthor_spec is not None:
|
|
119
|
+
pending.append(coauthor_spec)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if key not in AI_TRAILER_KEYS:
|
|
123
|
+
# Non-AI, non-co-author trailers are ignored without affecting
|
|
124
|
+
# grouping.
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
if current_group is None:
|
|
128
|
+
current_group = _Group()
|
|
129
|
+
pending.append(current_group)
|
|
130
|
+
elif value and key in current_group.values:
|
|
131
|
+
# Only a NON-EMPTY repeat closes the group (ADR-005): an
|
|
132
|
+
# empty-valued repeat contributes presence without splitting.
|
|
133
|
+
current_group = _Group()
|
|
134
|
+
pending.append(current_group)
|
|
135
|
+
current_group.present.add(key)
|
|
136
|
+
if value:
|
|
137
|
+
current_group.values[key] = value
|
|
138
|
+
|
|
139
|
+
for item in pending:
|
|
140
|
+
if isinstance(item, _Group):
|
|
141
|
+
spec, group_warnings = _finalize_group(item)
|
|
142
|
+
warnings.extend(group_warnings)
|
|
143
|
+
if spec is not None:
|
|
144
|
+
specs.append(spec)
|
|
145
|
+
else:
|
|
146
|
+
specs.append(item)
|
|
147
|
+
|
|
148
|
+
return specs, warnings
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _Group:
|
|
152
|
+
"""One AI-* trailer group: parsed non-empty values plus the set of
|
|
153
|
+
recognized keys that APPEARED (even with empty values — gate M-03)."""
|
|
154
|
+
|
|
155
|
+
__slots__ = ("values", "present")
|
|
156
|
+
|
|
157
|
+
def __init__(self) -> None:
|
|
158
|
+
self.values: dict[str, str] = {}
|
|
159
|
+
self.present: set[str] = set()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _split_trailer_line(line: str) -> tuple[str | None, str]:
|
|
163
|
+
"""Split one already-unfolded trailer line on the first ':'. A line with
|
|
164
|
+
no ':' yields (None, "") so the caller ignores it; the key is lowercased
|
|
165
|
+
and both sides are stripped (docstring contract)."""
|
|
166
|
+
key_part, sep, value_part = line.partition(":")
|
|
167
|
+
if not sep:
|
|
168
|
+
return None, ""
|
|
169
|
+
return key_part.strip().lower(), value_part.strip()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _finalize_group(group: _Group) -> tuple[ParticipationSpec | None, list[ParseWarning]]:
|
|
173
|
+
"""Turn one collected AI-* trailer group into a spec, or drop it with a
|
|
174
|
+
group-level warning (ADR-005 grouping rule + the two carved Human-Only
|
|
175
|
+
exceptions; schema.md section 5)."""
|
|
176
|
+
values = group.values
|
|
177
|
+
provider_raw_value = values.get("ai-provider")
|
|
178
|
+
tool_raw_value = values.get("ai-tool")
|
|
179
|
+
tool_resolved = resolve_tool(tool_raw_value) if tool_raw_value is not None else None
|
|
180
|
+
has_anchor = provider_raw_value is not None or tool_resolved is not None
|
|
181
|
+
|
|
182
|
+
mode_raw_value = values.get("ai-mode")
|
|
183
|
+
mode_candidate = _normalize_mode(mode_raw_value) if mode_raw_value is not None else None
|
|
184
|
+
is_human_only = mode_candidate is ContributionMode.HUMAN_ONLY
|
|
185
|
+
|
|
186
|
+
if is_human_only:
|
|
187
|
+
# Contradiction keys on KEY PRESENCE — even an empty-valued
|
|
188
|
+
# `AI-Provider:`/`AI-Tool:` line next to Human-Only is a
|
|
189
|
+
# contradictory declaration, never a clean human record
|
|
190
|
+
# (ADR-005; gate M-03).
|
|
191
|
+
if {"ai-provider", "ai-tool"} & group.present:
|
|
192
|
+
return None, [ParseWarning("contradictory-group", "ai-mode")]
|
|
193
|
+
return _build_human_spec(values)
|
|
194
|
+
|
|
195
|
+
if not has_anchor:
|
|
196
|
+
anchor_key = next(iter(values), None) or next(iter(sorted(group.present)))
|
|
197
|
+
return None, [ParseWarning("incomplete-group", anchor_key)]
|
|
198
|
+
|
|
199
|
+
return _build_ai_spec(values, provider_raw_value, tool_raw_value, tool_resolved)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _build_human_spec(group: dict[str, str]) -> tuple[ParticipationSpec, list[ParseWarning]]:
|
|
203
|
+
"""AI-Mode: Human-Only with no AI provider/tool anchor → one HUMAN spec,
|
|
204
|
+
all six identity fields None (schema.md section 2)."""
|
|
205
|
+
warnings: list[ParseWarning] = []
|
|
206
|
+
roles, role_warnings = _parse_roles(group.get("ai-role"))
|
|
207
|
+
warnings.extend(role_warnings)
|
|
208
|
+
human_reviewed, reviewed_warning = _parse_reviewed_by(group.get("ai-reviewed-by"))
|
|
209
|
+
if reviewed_warning is not None:
|
|
210
|
+
warnings.append(reviewed_warning)
|
|
211
|
+
|
|
212
|
+
spec = ParticipationSpec(
|
|
213
|
+
actor_type=ActorType.HUMAN,
|
|
214
|
+
provider=None,
|
|
215
|
+
provider_raw=None,
|
|
216
|
+
model=None,
|
|
217
|
+
model_raw=None,
|
|
218
|
+
tool=None,
|
|
219
|
+
tool_raw=None,
|
|
220
|
+
roles=roles,
|
|
221
|
+
contribution_mode=ContributionMode.HUMAN_ONLY,
|
|
222
|
+
human_reviewed=human_reviewed,
|
|
223
|
+
source=ProvenanceSource(
|
|
224
|
+
source_type=SourceType.GIT_TRAILER,
|
|
225
|
+
evidence_level=EvidenceLevel.DECLARED,
|
|
226
|
+
source_reference="ai-mode",
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
return spec, warnings
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_ai_spec(
|
|
233
|
+
group: dict[str, str],
|
|
234
|
+
provider_raw_value: str | None,
|
|
235
|
+
tool_raw_value: str | None,
|
|
236
|
+
tool_resolved: tuple[str, str] | None,
|
|
237
|
+
) -> tuple[ParticipationSpec, list[ParseWarning]]:
|
|
238
|
+
"""A group anchored by AI-Provider and/or a resolvable AI-Tool → one AI
|
|
239
|
+
spec (schema.md section 10 normalization; explicit AI-Provider always
|
|
240
|
+
wins the provider fields over a tool's owning provider)."""
|
|
241
|
+
warnings: list[ParseWarning] = []
|
|
242
|
+
|
|
243
|
+
if provider_raw_value is not None:
|
|
244
|
+
provider = normalize_provider(provider_raw_value)
|
|
245
|
+
provider_raw = provider_raw_value
|
|
246
|
+
elif tool_resolved is not None:
|
|
247
|
+
provider = tool_resolved[1]
|
|
248
|
+
provider_raw = None
|
|
249
|
+
else:
|
|
250
|
+
provider = None
|
|
251
|
+
provider_raw = None
|
|
252
|
+
|
|
253
|
+
if tool_raw_value is not None:
|
|
254
|
+
tool = tool_resolved[0] if tool_resolved is not None else None
|
|
255
|
+
tool_raw = tool_raw_value
|
|
256
|
+
else:
|
|
257
|
+
tool = None
|
|
258
|
+
tool_raw = None
|
|
259
|
+
|
|
260
|
+
model_raw_value = group.get("ai-model")
|
|
261
|
+
if model_raw_value is not None:
|
|
262
|
+
model = model_raw_value.strip().lower()
|
|
263
|
+
model_raw = model_raw_value
|
|
264
|
+
else:
|
|
265
|
+
model = None
|
|
266
|
+
model_raw = None
|
|
267
|
+
|
|
268
|
+
roles, role_warnings = _parse_roles(group.get("ai-role"))
|
|
269
|
+
warnings.extend(role_warnings)
|
|
270
|
+
|
|
271
|
+
mode_raw_value = group.get("ai-mode")
|
|
272
|
+
mode: ContributionMode | None = None
|
|
273
|
+
if mode_raw_value is not None:
|
|
274
|
+
mode = _normalize_mode(mode_raw_value)
|
|
275
|
+
if mode is None:
|
|
276
|
+
warnings.append(ParseWarning("malformed-mode", "ai-mode", local_detail=mode_raw_value))
|
|
277
|
+
|
|
278
|
+
human_reviewed, reviewed_warning = _parse_reviewed_by(group.get("ai-reviewed-by"))
|
|
279
|
+
if reviewed_warning is not None:
|
|
280
|
+
warnings.append(reviewed_warning)
|
|
281
|
+
|
|
282
|
+
source_reference = "ai-provider" if "ai-provider" in group else "ai-tool"
|
|
283
|
+
spec = ParticipationSpec(
|
|
284
|
+
actor_type=ActorType.AI,
|
|
285
|
+
provider=provider,
|
|
286
|
+
provider_raw=provider_raw,
|
|
287
|
+
model=model,
|
|
288
|
+
model_raw=model_raw,
|
|
289
|
+
tool=tool,
|
|
290
|
+
tool_raw=tool_raw,
|
|
291
|
+
roles=roles,
|
|
292
|
+
contribution_mode=mode,
|
|
293
|
+
human_reviewed=human_reviewed,
|
|
294
|
+
source=ProvenanceSource(
|
|
295
|
+
source_type=SourceType.GIT_TRAILER,
|
|
296
|
+
evidence_level=EvidenceLevel.DECLARED,
|
|
297
|
+
source_reference=source_reference,
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
return spec, warnings
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _normalize_mode(raw: str) -> ContributionMode | None:
|
|
304
|
+
"""Lowercase, '-'/' ' -> '_', then ContributionMode lookup; None when the
|
|
305
|
+
normalized token is not a recognized mode (docstring contract)."""
|
|
306
|
+
normalized = raw.strip().lower().replace("-", "_").replace(" ", "_")
|
|
307
|
+
try:
|
|
308
|
+
return ContributionMode(normalized)
|
|
309
|
+
except ValueError:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_roles(raw: str | None) -> tuple[tuple[Role, ...], list[ParseWarning]]:
|
|
314
|
+
"""Comma-separated AI-Role tokens → Role vocabulary; unknown tokens are
|
|
315
|
+
dropped with an `unknown-role` warning; missing key or empty result is
|
|
316
|
+
`()` (docstring contract)."""
|
|
317
|
+
if raw is None:
|
|
318
|
+
return (), []
|
|
319
|
+
warnings: list[ParseWarning] = []
|
|
320
|
+
roles: list[Role] = []
|
|
321
|
+
for token in raw.split(","):
|
|
322
|
+
token = token.strip()
|
|
323
|
+
if not token:
|
|
324
|
+
continue
|
|
325
|
+
try:
|
|
326
|
+
role = Role(token.lower())
|
|
327
|
+
except ValueError:
|
|
328
|
+
warnings.append(ParseWarning("unknown-role", "ai-role", local_detail=token))
|
|
329
|
+
continue
|
|
330
|
+
roles.append(role)
|
|
331
|
+
return tuple(roles), warnings
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _parse_reviewed_by(raw: str | None) -> tuple[bool | None, ParseWarning | None]:
|
|
335
|
+
""""human" -> True, "none" -> False, anything else -> None + a
|
|
336
|
+
`malformed-reviewed-by` warning; a missing key -> None with no warning
|
|
337
|
+
(docstring contract)."""
|
|
338
|
+
if raw is None:
|
|
339
|
+
return None, None
|
|
340
|
+
normalized = raw.strip().lower()
|
|
341
|
+
if normalized == "human":
|
|
342
|
+
return True, None
|
|
343
|
+
if normalized == "none":
|
|
344
|
+
return False, None
|
|
345
|
+
return None, ParseWarning("malformed-reviewed-by", "ai-reviewed-by", local_detail=raw)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
#: "Name <email>", tolerant of extra spaces around the name and brackets.
|
|
349
|
+
_COAUTHOR_RE = re.compile(r"^\s*(?P<name>.*?)\s*<\s*(?P<email>[^<>\s]+)\s*>\s*$")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _parse_coauthor(value: str) -> ParticipationSpec | None:
|
|
353
|
+
""""Name <email>" -> AI spec via registry.match_coauthor; a malformed
|
|
354
|
+
shape (no "<email>") or no registry match is silently ignored
|
|
355
|
+
(docstring contract; ADR-013)."""
|
|
356
|
+
match = _COAUTHOR_RE.match(value)
|
|
357
|
+
if match is None:
|
|
358
|
+
return None
|
|
359
|
+
name = match.group("name").strip()
|
|
360
|
+
email = match.group("email").strip()
|
|
361
|
+
identity = match_coauthor(name, email)
|
|
362
|
+
if identity is None:
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
return ParticipationSpec(
|
|
366
|
+
actor_type=ActorType.AI,
|
|
367
|
+
provider=identity.provider,
|
|
368
|
+
provider_raw=name,
|
|
369
|
+
model=None,
|
|
370
|
+
model_raw=None,
|
|
371
|
+
tool=identity.tool,
|
|
372
|
+
tool_raw=None,
|
|
373
|
+
roles=(),
|
|
374
|
+
contribution_mode=None,
|
|
375
|
+
human_reviewed=None,
|
|
376
|
+
source=ProvenanceSource(
|
|
377
|
+
source_type=SourceType.GIT_TRAILER_COAUTHOR,
|
|
378
|
+
evidence_level=EvidenceLevel.DECLARED,
|
|
379
|
+
source_reference=COAUTHOR_KEY,
|
|
380
|
+
),
|
|
381
|
+
)
|