consequence-ledger 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.
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: consequence-ledger
3
+ Version: 0.3.0
4
+ Summary: Measure what AI coding decisions actually cause: randomized routing, delayed outcome labels, causal estimates.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/sujan-mitta/consequence-ledger
7
+ Project-URL: Source, https://github.com/sujan-mitta/consequence-ledger
8
+ Project-URL: Changelog, https://github.com/sujan-mitta/consequence-ledger/blob/main/CHANGELOG.md
9
+ Keywords: ai,coding-agents,causal-inference,off-policy-evaluation,szz,git,software-engineering
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Classifier: Topic :: Software Development :: Version Control :: Git
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # Consequence Ledger
29
+
30
+ **Which AI coding tool actually *causes* fewer reverted and bug-fixed changes on your code?**
31
+
32
+ AI tools are judged on instant signals: a suggestion accepted, tests passing, a developer saying it felt faster. What matters arrives weeks later: the change gets reverted, or someone ships a fix for it. Comparing tools on those later outcomes is misleading unless you account for *which tasks each tool was given*. This prototype records that properly and turns it into cause-and-effect estimates.
33
+
34
+ It is the 90-day MVP from the "AI elephant in the room" research: decision logging with propensities, commit attribution, revert and SZZ outcome labels, and IPW / doubly-robust estimates with confidence intervals. It is pure Python standard library, with no dependencies beyond `git`.
35
+
36
+ ## Install
37
+
38
+ Python 3.9+ and git 2.28+. There are no dependencies.
39
+
40
+ ```bash
41
+ git clone https://github.com/sujan-mitta/consequence-ledger.git
42
+ cd consequence-ledger
43
+ pip install . # gives you the `ledger` command; or skip this and use `python -m ledger` from the folder
44
+ ledger --version
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ ```bash
50
+ python -m ledger demo
51
+ ```
52
+
53
+ This builds a synthetic repository with about 3,700 commits and a known true effect for each agent, then runs the whole pipeline and writes an HTML report. You'll see the naive comparison blame the wrong agent and the causal estimate recover the truth. A copy of the output is in [examples/demo-report.html](examples/demo-report.html).
54
+
55
+ ```bash
56
+ python -m ledger audit --repo path/to/repo --html audit.html
57
+ ```
58
+
59
+ This gives a day-one baseline from git history alone: revert and fix rates per tool, detected from commit trailers and bot authors (Claude Code, Copilot, Cursor, Devin, Jules, Codex, Aider, OpenHands). It is **correlational**, and the output says so. See [examples/manifold-audit.html](examples/manifold-audit.html) for a run on a real open-source repo.
60
+
61
+ ## Using it on a real repo
62
+
63
+ ```bash
64
+ python -m ledger init # create .ledger/ and install the commit hook
65
+ python -m ledger decide --arms claude-code,codex,cursor \
66
+ --scores 1.0,0.6,0.4 --epsilon 0.4 \
67
+ --context '{"stratum": "bugfix"}' # prints which tool to use for this task
68
+ # ... do the task with that tool; commits on this branch are tagged "Ledger-Decision-Id: <id>" automatically
69
+ python -m ledger done # stop tagging on this branch (--all: every branch)
70
+ python -m ledger attach --decision <id> --commit <sha> # if a squash merge dropped the trailer
71
+
72
+ # optional: production incidents and deploys exported from Sentry, PagerDuty, incident.io …
73
+ python -m ledger import incidents --file incidents.csv # id, started_at, title, commits, files
74
+ python -m ledger import deploys --file deploys.csv # sha, deployed_at
75
+
76
+ # or pull incidents live (read-only key in an environment variable, never an argument)
77
+ python -m ledger sync sentry --org your-org-slug --days 90 # needs SENTRY_AUTH_TOKEN
78
+ python -m ledger sync pagerduty --days 90 # needs PAGERDUTY_API_KEY
79
+
80
+ # before you start, and any time you wonder if something is stuck
81
+ python -m ledger plan --base-rate 0.12 --effect -0.03 # how many finished decisions you need
82
+ python -m ledger status # gaps that would mislead an estimate
83
+
84
+ # a week in: an early readout; weeks later: the real one
85
+ python -m ledger label --fix-style keywords # reverts, fixes and incidents within 30 days
86
+ python -m ledger estimate # causal estimates with 95% CIs
87
+ python -m ledger report # .ledger/report.html
88
+
89
+ # once you have a few hundred finished decisions
90
+ python -m ledger breakdown --stratum difficulty # which tool suits which kind of task
91
+ python -m ledger recommend --stratum difficulty # and what routing by that is honestly worth
92
+ ```
93
+
94
+ | Command | What it does |
95
+ |---|---|
96
+ | `init` | Creates `.ledger/` (self-ignored by git) and installs a `prepare-commit-msg` hook. It won't overwrite a hook you already have, and re-running it upgrades an older ledger hook. |
97
+ | `decide` | Chooses an arm at random with `p = (1-ε)·softmax(score/τ) + ε/k` and logs every arm's probability. The decision is active on the branch you are on, so several agents can work on several branches or git worktrees at once without sharing a decision. All worktrees and subfolders use one ledger at the repository root. |
98
+ | `sync sentry` / `sync pagerduty` | Pull error-level Sentry issues or high-urgency PagerDuty incidents as incidents. Keys come only from `SENTRY_AUTH_TOKEN` / `PAGERDUTY_API_KEY` and are never printed, even in errors. Neither API names the commit that caused an incident, so these carry timing only: link them with an export that names commits, or `--incident-linking`. Re-syncing never erases links from an earlier export. |
99
+ | `plan` | Simulates your routing and tells you how many finished decisions give 80% power, both for one look and for checking repeatedly. |
100
+ | `status` | Flags what would mislead an estimate: decisions with no commit, stale labels, near-zero routing chances, one-arm ledgers, incidents that can't be linked. |
101
+ | `import` | Loads incident or deploy exports (CSV or JSON, common field names accepted). |
102
+ | `label` | Maps commits to decisions and finds reverts, fixes (SZZ via `git blame`) and linked incidents. A decision counts only once its whole window has passed; a separate early label (`--early-days 7`) feeds the early readout. |
103
+ | `estimate` | Naive, IPW, self-normalised IPW, direct method and doubly robust estimates per arm. Bootstrap and influence-function CIs, effective sample size, minimum propensity, and a check that unmerged work is balanced across arms. `--cluster developer` resamples whole clusters when outcomes are correlated. |
104
+ | `breakdown` | Which tool is better for which kind of task (`--stratum difficulty`). Raw and shrunk effects per stratum with Bonferroni-adjusted p-values, so a lucky stratum is not mistaken for a pattern. |
105
+ | `recommend` | What routing each task type to its best tool would be worth. The tool is chosen on one half of your decisions and judged on the other, so the gain is not inflated; it also shows the gain from simply using the best tool. |
106
+ | `report` | A self-contained HTML report (light and dark). |
107
+ | `audit` | Per-tool baseline from history, with Wilson CIs and size-adjusted rates. |
108
+ | `demo` | Synthetic end-to-end run that checks labels against an oracle. |
109
+
110
+ Useful options:
111
+ - `--fix-style keywords` for repos that write "Fix crash in …" rather than `fix: …`
112
+ - `--fix-regex` for anything else
113
+ - `--ignore-revs .git-blame-ignore-revs` to see through formatting commits
114
+ - `--horizon 30`, `--as-of 2026-06-30`, `--baseline <arm>`, `--clip 20`
115
+ - `--cluster developer` (any context key) for cluster-robust intervals
116
+ - `--stratum difficulty,language` to model outcomes per combination of context keys
117
+ - `--incident-linking latest|window` to also link incidents that name no suspect commit (off by default; see below)
118
+ - `--planned-n 4000` sets the sample size the anytime-valid interval is tuned for. Choose it before you start.
119
+ - `--folds 5` cross-fits the outcome model (0 turns it off). It matters when strata are many and small: in
120
+ simulation the interval covered the truth 87.7% of the time without it and 95.3% with it.
121
+
122
+ **Checking every week?** Ordinary 95% intervals don't hold up to repeated looks: in simulation, 16 weekly checks under a true null "found" a winner 30% of the time. Each verdict therefore also says whether it survives the anytime-valid confidence sequence (Waudby-Smith et al., *Annals of Statistics* 2024), which kept that rate at 3%.
123
+
124
+ The report warns when:
125
+ - one arm's work goes unmerged more often (a chi-square test), since that biases the merged-only comparison
126
+ - an arm had a near-zero chance of being picked for some tasks
127
+ - an arm's effective sample size is small
128
+
129
+ ## How it decides "bad"
130
+
131
+ A change is bad if, within the horizon after its first commit:
132
+ - it is **reverted**: git's `This reverts commit <sha>`, or a squash-merged GitHub `Revert "<subject>"` PR, or
133
+ - a **fix commit** changes lines it introduced (SZZ), or names its PR ("Bug introduced in #1507", "regression from #88"), or
134
+ - a **production incident** names it as a suspect commit.
135
+
136
+ Incidents that name only files are left unlinked by default. In testing, blaming "the latest deploy that touched those files" found the true culprit only 14% of the time, because files change often. A missed incident can only shrink a difference between tools; a wrongly blamed one adds noise. Export suspect commits where your tooling has them (Sentry does).
137
+
138
+ **Early readout.** `estimate` also reports differences on a 7-day label, which includes recent decisions the main estimate must exclude. Next to it, the report shows how 7-day and 30-day differences compared on decisions old enough to have both. In the demo only 20% of bad outcomes surfaced within a week, and one tool's early difference pointed the wrong way. Treat the early readout as a hint until that comparison says otherwise.
139
+
140
+ The SZZ step includes the standard refinements against false positives. Each one mattered on real data:
141
+ - ignore whitespace-only edits (`-w`), and blank or comment-only lines
142
+ - ignore lines that were moved rather than changed
143
+ - skip tests, docs, editor and CI config, and lockfiles
144
+ - never treat a merge commit as a fix, even one titled "Merge pull request … from me/fix-crash"
145
+ - ignore a fix that blames more than 10 commits (`--max-blamed`, 0 to keep all): the research found such fixes are outliers, and cutting them raised precision
146
+ - optionally look through listed formatting commits
147
+
148
+ **Squash merges.** GitHub's squash merge drops the `Ledger-Decision-Id` trailer, which would make merged work look abandoned. `label` recovers these automatically: it fingerprints the branch's combined diff (`git patch-id --stable`) and looks for a commit on your branch with the same fingerprint. It only accepts a unique match that is newer than the branch's first commit, never guesses, and reports `squash_recovered`. It needs the branch's commits to still exist locally (a branch, a remote-tracking ref, or the reflog); `ledger attach` covers anything else.
149
+
150
+ Fix analysis is remembered in the ledger, so re-running `label` is near-instant: the same repository went from 46 seconds to under a tenth of a second.
151
+
152
+ ## What we learned
153
+
154
+ Full details are in [research/FINDINGS.md](research/FINDINGS.md):
155
+ - Doubly-robust estimates are unbiased (< 0.1 pp) with 93.5–95% bootstrap CI coverage. Naive comparisons were off by up to 7 pp and picked the wrong winner.
156
+ - Re-checking weekly with ordinary CIs gives a **30% false-positive rate**. The anytime-valid sequence gives 3%.
157
+ - How much data you need depends on your routing. A 3 pp difference needed about 8,000 mature decisions when routing was lopsided (the demo, ε = 0.1) but about 4,000 with near 50/50 routing, or about 8,000 if you also want it safe to check repeatedly. Run `ledger plan` with your own numbers.
158
+ - Label noise inflates levels but mostly leaves differences between arms alone. Missed bugs only shrink effects. Report differences.
159
+ - On elalish/manifold, Claude Code commits looked worse raw (11.3% vs 7.5%) and about the same size-adjusted (7.1% vs 8.1%). The CIs overlap, which is the case for randomizing.
160
+
161
+ ## Documentation
162
+
163
+ - [docs/METHODOLOGY.md](docs/METHODOLOGY.md): exactly what is computed, the assumptions it needs, and what it cannot tell you
164
+ - [research/FINDINGS.md](research/FINDINGS.md): simulation studies and real-repository results
165
+ - [CHANGELOG.md](CHANGELOG.md), [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md)
166
+
167
+ ## Layout
168
+
169
+ ```
170
+ ledger/ the package: policy (routing), label + squash (outcomes), estimate, audit, plan, status,
171
+ incidents + sentry + pagerduty, report, demo, db, gitio, cli
172
+ tests/ 230 unittest cases: real git repos in temp dirs, local fake servers, estimator Monte Carlo checks
173
+ tools/ coverage_lite.py, dependency-free line coverage
174
+ docs/ METHODOLOGY.md
175
+ research/ simulate.py (coverage, power, noise, design, peeking studies), results/, FINDINGS.md
176
+ examples/ generated demo report and the manifold audit
177
+ ```
178
+
179
+ Run the tests:
180
+
181
+ ```bash
182
+ python -m unittest discover -s tests
183
+ ```
184
+
185
+ ## Limits (honest list)
186
+
187
+ - Sentry and PagerDuty can be synced live, but the syncs were only tested against fake servers and (for Sentry) an empty account; others need exports. Customer impact (tickets, churn) isn't linked.
188
+ - The outcome model for DR is a per-stratum mean. Many context keys at once need a real model with cross-fitting.
189
+ - The early readout is a plain shorter window, not a calibrated surrogate index.
190
+ - Audit mode is observational. It describes; it does not prove.
191
+ - Python 3.9+ and git 2.28+.
@@ -0,0 +1,24 @@
1
+ consequence_ledger-0.3.0.dist-info/licenses/LICENSE,sha256=1lhJHdAJxo3B2z9l2KvKj6DLRXalmiELd5S_MrlW7eI,1068
2
+ ledger/__init__.py,sha256=IYhnUw-2ec9okTh6GjUpkWAtLikQOHVDK3bAHaJMIZY,97
3
+ ledger/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
4
+ ledger/audit.py,sha256=QBQQzyTL6cqZIWVd2Wt4QvTf4ohn-rbG2J4SBmygwyI,8594
5
+ ledger/cli.py,sha256=P8Orum30XdXOh5xH7SvXqrEgWBX07qYU1PKnV1LmVrM,32543
6
+ ledger/db.py,sha256=ddY_bfHUnXI9Vd0t8nd5kcuorHCRYv7gmLs1awinwSU,2874
7
+ ledger/demo.py,sha256=9doGAgvEdnA2TRy-4CF42ZsQLtbNGHGIoqma4_NFdBA,13663
8
+ ledger/estimate.py,sha256=hOOxmkrsvYHxamGHn3tq9wpwHJNhU5weGVvpXKHcaJo,17809
9
+ ledger/gitio.py,sha256=ix5jWWSbBAGHR0GT_QoH4jehPCfrl2VtlSwx7zkMkow,3946
10
+ ledger/incidents.py,sha256=iDZR450x9v-8S020zT2u7aG5SQ7Q4z4KdOTfdXy7vg4,8433
11
+ ledger/label.py,sha256=b9rXveX2LsQOpvl5K3-iYQwhHENzT6SMKzrabXZmr8E,18135
12
+ ledger/pagerduty.py,sha256=K7LLuhN_aUxn3jV0bdFbj0k9OYnEjTYk3YdYiuRZ1eQ,4294
13
+ ledger/plan.py,sha256=Y-DgVJ5XL9IBmClRJsNomG6K7u_xpQNVO7F3WV0oCd0,2922
14
+ ledger/policy.py,sha256=f8HvrS3KJgIfPhdifD6WHpmLE3qyrPyw1f4uVKyz_5k,2109
15
+ ledger/report.py,sha256=EBph-J7oKU6ZdMOCuURheMw434K35h99_Pif3Px9aFs,20938
16
+ ledger/segments.py,sha256=wGr98sP5vBSL_LJrMQphV8LZTnYeRc-Pkhn5-RQFQHk,6766
17
+ ledger/sentry.py,sha256=UGWldoC2fTgAqqXxK8e4ag6N3aIg_YhnZrweMCHYvQU,3884
18
+ ledger/squash.py,sha256=kzKkNHHXgtbwslQ8BGdh01ypGtcMctZCmdQNf7IYNkE,4231
19
+ ledger/status.py,sha256=QwduB9ekijadNTygPIuozj88dvBnhfpofMl3It-2S6c,3352
20
+ consequence_ledger-0.3.0.dist-info/METADATA,sha256=vBU7whqfE6-jekCjSjHjkKL2suzCFM747nYLvgmdZT0,14236
21
+ consequence_ledger-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
22
+ consequence_ledger-0.3.0.dist-info/entry_points.txt,sha256=MMH2iMSp-kYEpL7AAmFfUgDn4iRIgkF-Pd04uPvAFOw,43
23
+ consequence_ledger-0.3.0.dist-info/top_level.txt,sha256=m7uk8iJxEERz_eg6MUtQ-DR9EFBsRR3PmqxMKlSXm_I,7
24
+ consequence_ledger-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ledger = ledger.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sujan-mitta
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.
@@ -0,0 +1 @@
1
+ ledger
ledger/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Consequence Ledger: measure what AI coding decisions actually cause."""
2
+ __version__ = "0.3.0"
ledger/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
ledger/audit.py ADDED
@@ -0,0 +1,187 @@
1
+ """Baseline audit: how often each tool's commits get reverted or fixed, from history alone.
2
+
3
+ No randomisation, so this is correlational: tools are used on different kinds
4
+ of work. It is what a team can learn on day one, before `decide` has logged
5
+ any propensities, and it shows which comparisons are worth randomising.
6
+ Size-adjusted rates (direct standardisation over commit-size bands) remove
7
+ one obvious confounder; they cannot remove the rest.
8
+ """
9
+ import math
10
+ import re
11
+ import time
12
+ from collections import defaultdict
13
+
14
+ from .gitio import GitError, git, lines_changed, log_commits
15
+ from .label import DAY, DEFAULT_MAX_BLAMED, FIX_RE, first_bad, is_fix, outcome_events
16
+
17
+ TRAILER_RE = re.compile(r"(?im)^co-authored-by:\s*(.+)$")
18
+ AUTHOR_EMAIL_RE = re.compile(r"<([^>]*)>\s*$")
19
+
20
+ # (tool, scope, pattern). Trailer patterns run on each Co-authored-by value on its own, so a commit
21
+ # that merely *mentions* Claude in its body is not an AI commit. Email patterns use bot-specific
22
+ # addresses so a human called Devin is not misclassified; GitHub bot addresses carry an "<id>+" prefix.
23
+ # First match wins.
24
+ RULES = [
25
+ ("claude-code", "message", r"Generated with \[?Claude Code"),
26
+ ("claude-code", "trailer", r"\bclaude\b|@anthropic\.com"),
27
+ ("devin", "email", r"^(\d+\+)?devin-ai-integration(\[bot\])?@"),
28
+ ("devin", "trailer", r"\bdevin-ai-integration\b|@devin\.ai\b"),
29
+ ("jules", "email", r"^(\d+\+)?google-labs-jules(\[bot\])?@"),
30
+ ("copilot", "email", r"^(\d+\+)?copilot@users\.noreply\.github\.com$|^(\d+\+)?copilot-swe-agent(\[bot\])?@"),
31
+ ("copilot", "trailer", r"\bcopilot\b"),
32
+ ("cursor", "email", r"^cursoragent@cursor\.com$"),
33
+ ("cursor", "trailer", r"@cursor\.(sh|com)\b|\bcursor( agent)?\b(?!-)"),
34
+ ("codex", "email", r"chatgpt-codex-connector"),
35
+ ("codex", "trailer", r"\bcodex\b|@openai\.com\b"),
36
+ ("gemini", "email", r"gemini-code-assist"),
37
+ ("gemini", "trailer", r"^gemini\b[^<]*<[^>]*(google|gemini)"),
38
+ ("aider", "trailer", r"\baider\b"),
39
+ ("aider", "author", r"\(aider\)\s*<"),
40
+ ("openhands", "email", r"^openhands@all-hands\.dev$"),
41
+ ("openhands", "trailer", r"\bopenhands\b"),
42
+ ]
43
+ # Files that show a tool is set up in a repository even when its commits carry no signature. A 2026 census of
44
+ # 180 million repositories found author-bot lookups recover only 3% of Claude Code commits, and only 64% of
45
+ # repositories with Claude config files also had a signed commit, so the audit would otherwise undercount.
46
+ CONFIG_FILES = [
47
+ ("claude-code", r"^CLAUDE\.md$|^\.claude/"),
48
+ ("gemini", r"^GEMINI\.md$"),
49
+ ("cursor", r"^\.cursorrules$|^\.cursor/"),
50
+ ("copilot", r"^\.github/copilot-instructions\.md$"),
51
+ ("aider", r"^\.aider\.conf\.yml$"),
52
+ ("windsurf", r"^\.windsurfrules$"),
53
+ ("devin", r"^\.devin\.yaml$"),
54
+ ("junie", r"^\.junie/"),
55
+ ("openhands", r"^\.openhands/"),
56
+ ("agents.md", r"^AGENTS\.md$"), # read by Codex and several other tools, so it names no single one
57
+ ]
58
+ SIZE_BANDS = [(0, 20, "1-20"), (21, 200, "21-200"), (201, float("inf"), "200+")]
59
+
60
+
61
+ def compile_rules(extra=()):
62
+ """extra: ["tool=regex", ...] matched against the whole message, checked before the built-ins."""
63
+ rules = []
64
+ for spec in extra:
65
+ tool, _, pattern = spec.partition("=")
66
+ if not tool or not pattern:
67
+ raise ValueError(f"rule must look like tool=regex, got {spec!r}")
68
+ rules.append((tool.strip(), "message", re.compile(pattern, re.I | re.M)))
69
+ rules += [(t, s, re.compile(p, re.I | re.M)) for t, s, p in RULES]
70
+ return rules
71
+
72
+
73
+ def classify(commit, rules):
74
+ trailers = [v.strip() for v in TRAILER_RE.findall(commit["body"])]
75
+ m = AUTHOR_EMAIL_RE.search(commit.get("author", ""))
76
+ email = m.group(1) if m else ""
77
+ for tool, scope, rx in rules:
78
+ if scope == "message" and rx.search(commit["body"]):
79
+ return tool
80
+ if scope == "trailer" and any(rx.search(t) for t in trailers):
81
+ return tool
82
+ if scope == "email" and rx.search(email):
83
+ return tool
84
+ if scope == "author" and rx.search(commit.get("author", "")):
85
+ return tool
86
+ return "human"
87
+
88
+
89
+ def wilson(k, n, z=1.959964):
90
+ if n == 0:
91
+ return float("nan"), float("nan")
92
+ p = k / n
93
+ centre = (p + z * z / (2 * n)) / (1 + z * z / n)
94
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
95
+ return max(0.0, centre - half), min(1.0, centre + half)
96
+
97
+
98
+ def band(size):
99
+ return next(name for lo, hi, name in SIZE_BANDS if lo <= size <= hi)
100
+
101
+
102
+ def detect_adoption(repo, rev="HEAD"):
103
+ """{tool: [config files]} present in the tree at rev; empty if the tree cannot be listed."""
104
+ try:
105
+ names = git(repo, "ls-tree", "-r", "--name-only", rev).splitlines()
106
+ except GitError:
107
+ return {}
108
+ found = {}
109
+ for tool, pattern in CONFIG_FILES:
110
+ rx = re.compile(pattern)
111
+ hits = [n for n in names if rx.search(n)]
112
+ if hits:
113
+ found[tool] = hits[:5]
114
+ return found
115
+
116
+
117
+ def detection_notes(adoption, tools, mature):
118
+ """Plain-language warnings where config files say a tool is in use but its commits are barely, or not, detected."""
119
+ notes = []
120
+ for tool, files in adoption.items():
121
+ if tool == "agents.md":
122
+ continue
123
+ tagged = tools.get(tool, {}).get("commits", 0)
124
+ if tagged == 0 or (mature and tagged / mature < 0.01):
125
+ what = "no commit" if tagged == 0 else f"only {tagged} of {mature} commits"
126
+ notes.append(f"{files[0]} is in the repository but {what} is signed as {tool}. Its commit signature may "
127
+ f"be switched off, so some of its work is probably counted under 'human'. Compare with "
128
+ f"`--rule '{tool}=<regex>'` if you know another marker.")
129
+ return notes
130
+
131
+
132
+ def audit(repo, horizon_days=30, as_of=None, since=None, rev="HEAD", rules=None, ignore_revs=(), fix_re=FIX_RE,
133
+ max_blamed=DEFAULT_MAX_BLAMED):
134
+ as_of = int(as_of if as_of is not None else time.time())
135
+ rules = rules or compile_rules()
136
+ commits = log_commits(repo, rev)
137
+ sizes = lines_changed(repo, rev)
138
+ horizon = horizon_days * DAY
139
+
140
+ targets = {}
141
+ for c in commits:
142
+ if len(c["parents"]) > 1 or c["sha"] not in sizes or sizes[c["sha"]] == 0:
143
+ continue # merges and empty commits
144
+ if since and c["ts"] < since:
145
+ continue
146
+ if c["ts"] + horizon > as_of:
147
+ continue # immature: its window hasn't closed
148
+ targets[c["sha"]] = classify(c, rules)
149
+ by_sha = {c["sha"]: c for c in commits}
150
+ szz_stats = {}
151
+ events = outcome_events(repo, commits, set(targets), ignore_revs, fix_re, stats=szz_stats, max_blamed=max_blamed)
152
+
153
+ groups = defaultdict(lambda: {"n": 0, "revert": 0, "fix": 0, "bands": defaultdict(lambda: [0, 0])})
154
+ band_totals = defaultdict(int)
155
+ for sha, tool in targets.items():
156
+ g = groups[tool]
157
+ hit = first_bad(events, [sha], by_sha[sha]["ts"], horizon)
158
+ b = band(sizes[sha])
159
+ g["n"] += 1
160
+ g["bands"][b][1] += 1
161
+ band_totals[b] += 1
162
+ if hit:
163
+ g[hit[1]] += 1
164
+ g["bands"][b][0] += 1
165
+
166
+ total = sum(band_totals.values())
167
+ tools = {}
168
+ for tool, g in sorted(groups.items(), key=lambda kv: -kv[1]["n"]):
169
+ bad = g["revert"] + g["fix"]
170
+ lo, hi = wilson(bad, g["n"])
171
+ # Direct standardisation: this tool's per-band rates applied to the repo-wide size mix.
172
+ covered = [b for b in band_totals if g["bands"][b][1] > 0]
173
+ weight = sum(band_totals[b] for b in covered)
174
+ adjusted = (sum(band_totals[b] * g["bands"][b][0] / g["bands"][b][1] for b in covered) / weight
175
+ if weight else float("nan"))
176
+ tools[tool] = {
177
+ "commits": g["n"], "reverted": g["revert"], "fixed": g["fix"],
178
+ "bad_rate": bad / g["n"], "ci_lo": lo, "ci_hi": hi,
179
+ "size_adjusted_rate": adjusted,
180
+ "size_bands": {b: {"bad": v[0], "n": v[1]} for b, v in sorted(g["bands"].items())},
181
+ "band_coverage": weight / total if total else 0.0,
182
+ }
183
+ adoption = detect_adoption(repo, rev)
184
+ return {"horizon_days": horizon_days, "as_of": as_of, "since": since, "commits_scanned": len(commits),
185
+ "mature_commits": len(targets), "fix_commits": sum(is_fix(c, fix_re) for c in commits),
186
+ "fixes_too_diffuse": szz_stats.get("fixes_too_diffuse", 0), "size_mix": dict(band_totals), "tools": tools,
187
+ "adoption": adoption, "detection_notes": detection_notes(adoption, tools, len(targets))}