culprit 0.1.0__tar.gz
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.
- culprit-0.1.0/LICENSE +21 -0
- culprit-0.1.0/PKG-INFO +150 -0
- culprit-0.1.0/README.md +131 -0
- culprit-0.1.0/culprit/__init__.py +9 -0
- culprit-0.1.0/culprit/_proc.py +46 -0
- culprit-0.1.0/culprit/blast_radius.py +124 -0
- culprit-0.1.0/culprit/classify.py +87 -0
- culprit-0.1.0/culprit/cli.py +152 -0
- culprit-0.1.0/culprit/config.py +43 -0
- culprit-0.1.0/culprit/evolution.py +171 -0
- culprit-0.1.0/culprit/htmlreport.py +37 -0
- culprit-0.1.0/culprit/pr_context.py +343 -0
- culprit-0.1.0/culprit/reasoning.py +100 -0
- culprit-0.1.0/culprit/report.py +88 -0
- culprit-0.1.0/culprit/serve.py +249 -0
- culprit-0.1.0/culprit/suspect.py +190 -0
- culprit-0.1.0/culprit/templates/report.html +471 -0
- culprit-0.1.0/culprit.egg-info/PKG-INFO +150 -0
- culprit-0.1.0/culprit.egg-info/SOURCES.txt +32 -0
- culprit-0.1.0/culprit.egg-info/dependency_links.txt +1 -0
- culprit-0.1.0/culprit.egg-info/entry_points.txt +3 -0
- culprit-0.1.0/culprit.egg-info/requires.txt +6 -0
- culprit-0.1.0/culprit.egg-info/top_level.txt +1 -0
- culprit-0.1.0/pyproject.toml +34 -0
- culprit-0.1.0/setup.cfg +4 -0
- culprit-0.1.0/tests/test_blast_radius.py +53 -0
- culprit-0.1.0/tests/test_classify.py +42 -0
- culprit-0.1.0/tests/test_config.py +25 -0
- culprit-0.1.0/tests/test_evolution.py +58 -0
- culprit-0.1.0/tests/test_htmlreport.py +81 -0
- culprit-0.1.0/tests/test_multihost.py +46 -0
- culprit-0.1.0/tests/test_pipeline.py +71 -0
- culprit-0.1.0/tests/test_serve.py +58 -0
- culprit-0.1.0/tests/test_suspect_parse.py +36 -0
culprit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Noordeen
|
|
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.
|
culprit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: culprit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Root-cause analysis for a PR or branch: classify feature vs bugfix, find the introducing commit (suspect set) or the blast radius.
|
|
5
|
+
Author: Noordeen
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/noordeen123/culprit
|
|
8
|
+
Project-URL: Repository, https://github.com/noordeen123/culprit
|
|
9
|
+
Project-URL: Issues, https://github.com/noordeen123/culprit/issues
|
|
10
|
+
Keywords: git,rca,root-cause,pull-request,regression,blame
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: api
|
|
15
|
+
Requires-Dist: anthropic>=0.40; extra == "api"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# culprit
|
|
21
|
+
|
|
22
|
+
Root-cause analysis for a pull request or branch.
|
|
23
|
+
|
|
24
|
+
`culprit` looks at a PR (or the current branch), decides whether it's a **bugfix**
|
|
25
|
+
or a **feature**, then:
|
|
26
|
+
|
|
27
|
+
- **Bugfix** → finds the commit that *introduced* the bug. It blames the lines the
|
|
28
|
+
fix removed/changed at the base revision and ranks the commits that last touched
|
|
29
|
+
them (the **suspect set**), then explains why it broke and whether the fix is
|
|
30
|
+
complete.
|
|
31
|
+
- **Feature** → maps the **blast radius**: who imports the changed modules, which
|
|
32
|
+
tests cover them, and which touched files live in high-risk shared/core areas.
|
|
33
|
+
|
|
34
|
+
It is **read-only** — it never modifies your repo or the PR.
|
|
35
|
+
|
|
36
|
+
## Why the split design
|
|
37
|
+
|
|
38
|
+
The deterministic git work (diff parsing, `git blame` / `git log -L`, the
|
|
39
|
+
suspect set, the reverse-import map) lives in a plain Python engine that emits
|
|
40
|
+
**structured JSON**. The only LLM step — the "why it broke" narrative — is
|
|
41
|
+
isolated behind a `ReasoningAdapter`:
|
|
42
|
+
|
|
43
|
+
- **HarnessAdapter** — used by the Claude Code skill. Returns the structured
|
|
44
|
+
result + a markdown skeleton; the agent writes the narrative. No API key.
|
|
45
|
+
- **ClaudeAPIAdapter** — used standalone. Calls the Claude API
|
|
46
|
+
(`claude-opus-4-8` by default, `--fast` → `claude-sonnet-4-6`).
|
|
47
|
+
|
|
48
|
+
Same engine, two frontends.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install -e . # engine + CLI
|
|
54
|
+
pip install -e ".[api]" # + Claude API reasoning layer (anthropic SDK)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
PR metadata uses the GitHub CLI when available: `brew install gh && gh auth login`.
|
|
58
|
+
For **public repos you don't even need `gh`** — `rca --pr N` falls back to the
|
|
59
|
+
unauthenticated REST API (**GitHub and GitLab**) for metadata plus a read-only
|
|
60
|
+
`git fetch` of the PR/MR head (set `GITHUB_TOKEN` / `GITLAB_TOKEN` to raise rate
|
|
61
|
+
limits). With neither, culprit uses local git (base vs head) — fully offline,
|
|
62
|
+
minus PR title/labels.
|
|
63
|
+
|
|
64
|
+
### Any host, any language
|
|
65
|
+
|
|
66
|
+
- **Hosts:** deep links (commit / PR / file) are generated for **GitHub, GitLab,
|
|
67
|
+
Bitbucket, and Gitea**; the suspect-set + line-evolution timeline work on *any*
|
|
68
|
+
git repo regardless of host. For a self-hosted forge the URL can't disambiguate,
|
|
69
|
+
so set `host = "gitlab"` (or `github`/`bitbucket`/`gitea`) in `.culprit.toml`, or
|
|
70
|
+
`CULPRIT_HOST`.
|
|
71
|
+
- **Languages:** suspect/timeline are language-agnostic (pure `git blame`/`log -L`).
|
|
72
|
+
Blast-radius + test-gap detect imports across JS/TS, Python, Go, Java/Kotlin,
|
|
73
|
+
Ruby, C/C++, C#, PHP, Rust, Scala, Swift (quoted *and* bare/dotted import forms).
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
rca # current branch vs the configured base (or latest commit)
|
|
79
|
+
rca --last # just the latest commit ("the change I just made")
|
|
80
|
+
rca --pr 16786 # a specific GitHub PR (uses the PR's own base)
|
|
81
|
+
rca --repo /path --base main
|
|
82
|
+
rca --mode api --fast # standalone reasoning via the Claude API
|
|
83
|
+
rca --json # structured result only
|
|
84
|
+
rca --html report.html --open # self-contained visual report (timeline UI)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Visual HTML report
|
|
88
|
+
|
|
89
|
+
`--html PATH` writes a **single self-contained HTML file** (inline CSS/JS, data
|
|
90
|
+
embedded, no CDN — opens offline, shareable, CI-attachable). For a bugfix it
|
|
91
|
+
renders a **line-evolution timeline**: for each line the fix touched, every commit
|
|
92
|
+
that ever changed those lines, from creation → … → **the commit that broke it
|
|
93
|
+
(red)** → **the fix (green)**, each step expandable to its diff.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
rca --pr 16889 --html rca.html --open # narrative via --mode api if key set
|
|
97
|
+
rca --pr 16889 --html rca.html --narrative-file why.md # embed a pre-written narrative
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The timeline needs no API key. The "Analysis" prose comes from `--narrative-file`
|
|
101
|
+
(e.g. written by the Claude Code `/rca` skill) or from `--mode api`.
|
|
102
|
+
|
|
103
|
+
The report also includes: a **TL;DR banner** naming the prime suspect and how long
|
|
104
|
+
the bug lived before the fix; **GitHub deep links** on every commit / PR / file
|
|
105
|
+
(derived from `origin`); **weight bars** ranking the suspects; **expand/collapse-all**
|
|
106
|
+
and a **per-file filter** for the timeline; and a one-click **copy-as-markdown** to
|
|
107
|
+
paste into the PR.
|
|
108
|
+
|
|
109
|
+
### Choosing the base branch
|
|
110
|
+
|
|
111
|
+
The base differs per repo (`main`, `master`, `develop`, a long-lived release
|
|
112
|
+
branch, …). Resolution order:
|
|
113
|
+
`--base <ref>` → `CULPRIT_BASE` env → `.culprit.toml` (`base = "..."`) → the latest
|
|
114
|
+
commit. The static HTML report is generated for one base (shown in the footer with a
|
|
115
|
+
regenerate hint). For an **interactive base picker**, use `serve` mode:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
rca serve --repo /path/to/repo # opens http://127.0.0.1:8722
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
It launches a local web app (stdlib only — no extra deps) with a form: enter a
|
|
122
|
+
PR/branch, **pick the base from a dropdown** (pre-filled from `.culprit.toml`,
|
|
123
|
+
the repo's default branch, then all local/remote branches), choose
|
|
124
|
+
classification + reasoning, and run a fresh analysis that renders the same visual
|
|
125
|
+
report. The base picker repopulates when you point it at a different repo. Binds
|
|
126
|
+
to localhost only.
|
|
127
|
+
|
|
128
|
+
### Base branch
|
|
129
|
+
|
|
130
|
+
In local mode (no PR), culprit needs a base to diff against. Resolution order:
|
|
131
|
+
|
|
132
|
+
1. `--base <ref>` on the CLI
|
|
133
|
+
2. `CULPRIT_BASE` environment variable
|
|
134
|
+
3. `base = "..."` in a `.culprit.toml` at the repo root
|
|
135
|
+
4. otherwise the latest commit (`HEAD~1`)
|
|
136
|
+
|
|
137
|
+
So pin your repo's real base once and forget it:
|
|
138
|
+
|
|
139
|
+
```toml
|
|
140
|
+
# .culprit.toml
|
|
141
|
+
base = "origin/main" # whatever your repo is actually cut from
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`--last` always forces the latest-commit view regardless of config.
|
|
145
|
+
|
|
146
|
+
## Tests
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install -e ".[dev]" && pytest
|
|
150
|
+
```
|
culprit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# culprit
|
|
2
|
+
|
|
3
|
+
Root-cause analysis for a pull request or branch.
|
|
4
|
+
|
|
5
|
+
`culprit` looks at a PR (or the current branch), decides whether it's a **bugfix**
|
|
6
|
+
or a **feature**, then:
|
|
7
|
+
|
|
8
|
+
- **Bugfix** → finds the commit that *introduced* the bug. It blames the lines the
|
|
9
|
+
fix removed/changed at the base revision and ranks the commits that last touched
|
|
10
|
+
them (the **suspect set**), then explains why it broke and whether the fix is
|
|
11
|
+
complete.
|
|
12
|
+
- **Feature** → maps the **blast radius**: who imports the changed modules, which
|
|
13
|
+
tests cover them, and which touched files live in high-risk shared/core areas.
|
|
14
|
+
|
|
15
|
+
It is **read-only** — it never modifies your repo or the PR.
|
|
16
|
+
|
|
17
|
+
## Why the split design
|
|
18
|
+
|
|
19
|
+
The deterministic git work (diff parsing, `git blame` / `git log -L`, the
|
|
20
|
+
suspect set, the reverse-import map) lives in a plain Python engine that emits
|
|
21
|
+
**structured JSON**. The only LLM step — the "why it broke" narrative — is
|
|
22
|
+
isolated behind a `ReasoningAdapter`:
|
|
23
|
+
|
|
24
|
+
- **HarnessAdapter** — used by the Claude Code skill. Returns the structured
|
|
25
|
+
result + a markdown skeleton; the agent writes the narrative. No API key.
|
|
26
|
+
- **ClaudeAPIAdapter** — used standalone. Calls the Claude API
|
|
27
|
+
(`claude-opus-4-8` by default, `--fast` → `claude-sonnet-4-6`).
|
|
28
|
+
|
|
29
|
+
Same engine, two frontends.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e . # engine + CLI
|
|
35
|
+
pip install -e ".[api]" # + Claude API reasoning layer (anthropic SDK)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
PR metadata uses the GitHub CLI when available: `brew install gh && gh auth login`.
|
|
39
|
+
For **public repos you don't even need `gh`** — `rca --pr N` falls back to the
|
|
40
|
+
unauthenticated REST API (**GitHub and GitLab**) for metadata plus a read-only
|
|
41
|
+
`git fetch` of the PR/MR head (set `GITHUB_TOKEN` / `GITLAB_TOKEN` to raise rate
|
|
42
|
+
limits). With neither, culprit uses local git (base vs head) — fully offline,
|
|
43
|
+
minus PR title/labels.
|
|
44
|
+
|
|
45
|
+
### Any host, any language
|
|
46
|
+
|
|
47
|
+
- **Hosts:** deep links (commit / PR / file) are generated for **GitHub, GitLab,
|
|
48
|
+
Bitbucket, and Gitea**; the suspect-set + line-evolution timeline work on *any*
|
|
49
|
+
git repo regardless of host. For a self-hosted forge the URL can't disambiguate,
|
|
50
|
+
so set `host = "gitlab"` (or `github`/`bitbucket`/`gitea`) in `.culprit.toml`, or
|
|
51
|
+
`CULPRIT_HOST`.
|
|
52
|
+
- **Languages:** suspect/timeline are language-agnostic (pure `git blame`/`log -L`).
|
|
53
|
+
Blast-radius + test-gap detect imports across JS/TS, Python, Go, Java/Kotlin,
|
|
54
|
+
Ruby, C/C++, C#, PHP, Rust, Scala, Swift (quoted *and* bare/dotted import forms).
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
rca # current branch vs the configured base (or latest commit)
|
|
60
|
+
rca --last # just the latest commit ("the change I just made")
|
|
61
|
+
rca --pr 16786 # a specific GitHub PR (uses the PR's own base)
|
|
62
|
+
rca --repo /path --base main
|
|
63
|
+
rca --mode api --fast # standalone reasoning via the Claude API
|
|
64
|
+
rca --json # structured result only
|
|
65
|
+
rca --html report.html --open # self-contained visual report (timeline UI)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Visual HTML report
|
|
69
|
+
|
|
70
|
+
`--html PATH` writes a **single self-contained HTML file** (inline CSS/JS, data
|
|
71
|
+
embedded, no CDN — opens offline, shareable, CI-attachable). For a bugfix it
|
|
72
|
+
renders a **line-evolution timeline**: for each line the fix touched, every commit
|
|
73
|
+
that ever changed those lines, from creation → … → **the commit that broke it
|
|
74
|
+
(red)** → **the fix (green)**, each step expandable to its diff.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
rca --pr 16889 --html rca.html --open # narrative via --mode api if key set
|
|
78
|
+
rca --pr 16889 --html rca.html --narrative-file why.md # embed a pre-written narrative
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The timeline needs no API key. The "Analysis" prose comes from `--narrative-file`
|
|
82
|
+
(e.g. written by the Claude Code `/rca` skill) or from `--mode api`.
|
|
83
|
+
|
|
84
|
+
The report also includes: a **TL;DR banner** naming the prime suspect and how long
|
|
85
|
+
the bug lived before the fix; **GitHub deep links** on every commit / PR / file
|
|
86
|
+
(derived from `origin`); **weight bars** ranking the suspects; **expand/collapse-all**
|
|
87
|
+
and a **per-file filter** for the timeline; and a one-click **copy-as-markdown** to
|
|
88
|
+
paste into the PR.
|
|
89
|
+
|
|
90
|
+
### Choosing the base branch
|
|
91
|
+
|
|
92
|
+
The base differs per repo (`main`, `master`, `develop`, a long-lived release
|
|
93
|
+
branch, …). Resolution order:
|
|
94
|
+
`--base <ref>` → `CULPRIT_BASE` env → `.culprit.toml` (`base = "..."`) → the latest
|
|
95
|
+
commit. The static HTML report is generated for one base (shown in the footer with a
|
|
96
|
+
regenerate hint). For an **interactive base picker**, use `serve` mode:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
rca serve --repo /path/to/repo # opens http://127.0.0.1:8722
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It launches a local web app (stdlib only — no extra deps) with a form: enter a
|
|
103
|
+
PR/branch, **pick the base from a dropdown** (pre-filled from `.culprit.toml`,
|
|
104
|
+
the repo's default branch, then all local/remote branches), choose
|
|
105
|
+
classification + reasoning, and run a fresh analysis that renders the same visual
|
|
106
|
+
report. The base picker repopulates when you point it at a different repo. Binds
|
|
107
|
+
to localhost only.
|
|
108
|
+
|
|
109
|
+
### Base branch
|
|
110
|
+
|
|
111
|
+
In local mode (no PR), culprit needs a base to diff against. Resolution order:
|
|
112
|
+
|
|
113
|
+
1. `--base <ref>` on the CLI
|
|
114
|
+
2. `CULPRIT_BASE` environment variable
|
|
115
|
+
3. `base = "..."` in a `.culprit.toml` at the repo root
|
|
116
|
+
4. otherwise the latest commit (`HEAD~1`)
|
|
117
|
+
|
|
118
|
+
So pin your repo's real base once and forget it:
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
# .culprit.toml
|
|
122
|
+
base = "origin/main" # whatever your repo is actually cut from
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`--last` always forces the latest-commit view regardless of config.
|
|
126
|
+
|
|
127
|
+
## Tests
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pip install -e ".[dev]" && pytest
|
|
131
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""culprit — root-cause analysis for a PR or branch.
|
|
2
|
+
|
|
3
|
+
Repo-agnostic engine: deterministic git/PR analysis that emits structured JSON.
|
|
4
|
+
The only LLM step (the "why it broke" narrative) is isolated behind
|
|
5
|
+
``culprit.reasoning`` so the same engine drives both the Claude Code skill
|
|
6
|
+
(harness reasons) and the standalone CLI (Claude API reasons).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Thin, read-only subprocess helpers for git and gh.
|
|
2
|
+
|
|
3
|
+
Every command here is read-only by construction. Nothing in culprit ever
|
|
4
|
+
mutates the target repository or the PR.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProcError(RuntimeError):
|
|
14
|
+
"""A subprocess exited non-zero."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, cmd: List[str], returncode: int, stderr: str):
|
|
17
|
+
self.cmd = cmd
|
|
18
|
+
self.returncode = returncode
|
|
19
|
+
self.stderr = stderr
|
|
20
|
+
super().__init__("`{}` exited {}: {}".format(" ".join(cmd), returncode, stderr.strip()))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(cmd: List[str], cwd: Optional[str] = None, check: bool = True) -> str:
|
|
24
|
+
"""Run a command and return stdout. Raise ProcError on failure when check."""
|
|
25
|
+
proc = subprocess.run(
|
|
26
|
+
cmd,
|
|
27
|
+
cwd=cwd,
|
|
28
|
+
stdout=subprocess.PIPE,
|
|
29
|
+
stderr=subprocess.PIPE,
|
|
30
|
+
text=True,
|
|
31
|
+
)
|
|
32
|
+
if check and proc.returncode != 0:
|
|
33
|
+
raise ProcError(cmd, proc.returncode, proc.stderr)
|
|
34
|
+
return proc.stdout
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def git(args: List[str], repo: str, check: bool = True) -> str:
|
|
38
|
+
return run(["git", "-C", repo] + args, check=check)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def have_gh() -> bool:
|
|
42
|
+
return shutil.which("gh") is not None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def gh(args: List[str], repo: str, check: bool = True) -> str:
|
|
46
|
+
return run(["gh"] + args, cwd=repo, check=check)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Feature path: what can this change break?
|
|
2
|
+
|
|
3
|
+
For each changed source file, find who imports it (reverse-import map), which
|
|
4
|
+
tests cover those modules, and which touched files live in shared/core areas
|
|
5
|
+
(high blast radius). Heuristic but grounded — the reasoning layer ranks risk
|
|
6
|
+
and recommends the test surface from this structured map.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from . import _proc
|
|
15
|
+
|
|
16
|
+
DEFAULT_SOURCE_GLOBS = [
|
|
17
|
+
"*.js", "*.jsx", "*.ts", "*.tsx", "*.mjs", "*.cjs", "*.vue", "*.svelte",
|
|
18
|
+
"*.py", "*.go", "*.rb", "*.java", "*.kt", "*.scala", "*.cs", "*.php",
|
|
19
|
+
"*.rs", "*.c", "*.h", "*.cc", "*.cpp", "*.hpp", "*.m", "*.swift",
|
|
20
|
+
]
|
|
21
|
+
# Test-file conventions across ecosystems: JS spec/test, Python test_*/*_test,
|
|
22
|
+
# Go *_test.go, Java/Kotlin/C# *Test/*Tests, Ruby *_spec, plus test dirs.
|
|
23
|
+
DEFAULT_TEST_RE = re.compile(
|
|
24
|
+
r"(\.spec\.|\.test\.|_test\.|_spec\.|/__tests__/|(^|/)cypress/|(^|/)tests?/"
|
|
25
|
+
r"|(^|/)test_[^/]*\.(py|rb)$|Tests?\.(java|kt|cs|scala|swift)$|_test\.go$)", re.I)
|
|
26
|
+
HIGH_RISK_RE = re.compile(r"(^|/)(shared|common|core|lib|utils?|helpers?|base|hooks|store)(/|$)", re.I)
|
|
27
|
+
|
|
28
|
+
_INDEX_RE = re.compile(r"(^|/)(index|__init__|mod)\.[^/]+$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _module_token(path: str) -> str:
|
|
32
|
+
"""The identifier other files most likely import this module by."""
|
|
33
|
+
if _INDEX_RE.search(path):
|
|
34
|
+
# package entry files (index.js / __init__.py / mod.go) are imported by dir name
|
|
35
|
+
return os.path.basename(os.path.dirname(path)) or os.path.basename(path)
|
|
36
|
+
return os.path.splitext(os.path.basename(path))[0]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _importers(repo: str, token: str, exclude: str, source_globs: List[str]) -> List[str]:
|
|
40
|
+
if not token:
|
|
41
|
+
return []
|
|
42
|
+
tok = re.escape(token)
|
|
43
|
+
# An import-ish line that references the token as a delimited path segment.
|
|
44
|
+
# Covers JS/TS (`import x from '…/token'`, `require('…token…')`), Python
|
|
45
|
+
# (`from a.token import x`, `import a.token`), Java (`import a.b.Token;`),
|
|
46
|
+
# Go/Ruby/C (`"…/token"`, `<token.h>`). Uses POSIX classes only — git grep -E
|
|
47
|
+
# has no \w / \b, so token boundaries are spelled [^A-Za-z0-9_].
|
|
48
|
+
pat = r"(import|require|include|from|use).*[^A-Za-z0-9_]{}([^A-Za-z0-9_]|$)".format(tok)
|
|
49
|
+
args = ["grep", "-l", "-I", "-E", "-e", pat, "--"] + source_globs
|
|
50
|
+
out = _proc.git(args, repo, check=False)
|
|
51
|
+
return [f for f in out.splitlines() if f.strip() and f != exclude]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_gap(changed_files: List[str], repo: str,
|
|
55
|
+
source_globs: Optional[List[str]] = None, max_files: int = 60) -> Dict[str, Any]:
|
|
56
|
+
"""For a bugfix: which changed (non-test) files have no covering tests.
|
|
57
|
+
|
|
58
|
+
A regression usually slips through because the touched code isn't tested.
|
|
59
|
+
Reuses the reverse-import map to find test files that import each module.
|
|
60
|
+
"""
|
|
61
|
+
source_globs = source_globs or DEFAULT_SOURCE_GLOBS
|
|
62
|
+
files = [f for f in changed_files if f]
|
|
63
|
+
notes: List[str] = []
|
|
64
|
+
if len(files) > max_files:
|
|
65
|
+
notes.append("{} files; checked the first {}".format(len(files), max_files))
|
|
66
|
+
files = files[:max_files]
|
|
67
|
+
covering = set()
|
|
68
|
+
untested: List[str] = []
|
|
69
|
+
for path in files:
|
|
70
|
+
if DEFAULT_TEST_RE.search(path):
|
|
71
|
+
continue # the changed file is itself a test
|
|
72
|
+
token = _module_token(path)
|
|
73
|
+
tests = [i for i in _importers(repo, token, path, source_globs) if DEFAULT_TEST_RE.search(i)]
|
|
74
|
+
if tests:
|
|
75
|
+
covering.update(tests)
|
|
76
|
+
else:
|
|
77
|
+
untested.append(path)
|
|
78
|
+
return {"untested": untested, "covering_tests": sorted(covering), "notes": notes}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def analyze(ctx: Dict[str, Any], repo: str,
|
|
82
|
+
source_globs: Optional[List[str]] = None,
|
|
83
|
+
max_dependents: int = 50, max_files: int = 200) -> Dict[str, Any]:
|
|
84
|
+
source_globs = source_globs or DEFAULT_SOURCE_GLOBS
|
|
85
|
+
changed = [f for f in ctx.get("changed_files", []) if f]
|
|
86
|
+
notes: List[str] = []
|
|
87
|
+
if len(changed) > max_files:
|
|
88
|
+
notes.append("changeset has {} files; mapping dependents for the first {} "
|
|
89
|
+
"(narrow the base or analyze one commit)".format(len(changed), max_files))
|
|
90
|
+
changed = changed[:max_files]
|
|
91
|
+
|
|
92
|
+
dependents: Dict[str, List[str]] = {}
|
|
93
|
+
covering_tests = set()
|
|
94
|
+
high_risk: List[str] = []
|
|
95
|
+
|
|
96
|
+
for path in changed:
|
|
97
|
+
if DEFAULT_TEST_RE.search(path):
|
|
98
|
+
covering_tests.add(path) # the change itself touches a test
|
|
99
|
+
if HIGH_RISK_RE.search(path):
|
|
100
|
+
high_risk.append(path)
|
|
101
|
+
|
|
102
|
+
token = _module_token(path)
|
|
103
|
+
imps = _importers(repo, token, path, source_globs)[:max_dependents]
|
|
104
|
+
if imps:
|
|
105
|
+
dependents[path] = imps
|
|
106
|
+
for imp in imps:
|
|
107
|
+
if DEFAULT_TEST_RE.search(imp):
|
|
108
|
+
covering_tests.add(imp)
|
|
109
|
+
|
|
110
|
+
# A changed file with many dependents is also high-risk even outside shared/.
|
|
111
|
+
for path, imps in dependents.items():
|
|
112
|
+
if len(imps) >= 10 and path not in high_risk:
|
|
113
|
+
high_risk.append(path)
|
|
114
|
+
|
|
115
|
+
ranked = sorted(dependents.items(), key=lambda kv: len(kv[1]), reverse=True)
|
|
116
|
+
return {
|
|
117
|
+
"changed_files": changed,
|
|
118
|
+
"dependents": dict(ranked),
|
|
119
|
+
"dependent_counts": {p: len(v) for p, v in ranked},
|
|
120
|
+
"covering_tests": sorted(covering_tests),
|
|
121
|
+
"high_risk": high_risk,
|
|
122
|
+
"total_dependents": sum(len(v) for v in dependents.values()),
|
|
123
|
+
"notes": notes,
|
|
124
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Classify a change as a bugfix or a feature, with evidence.
|
|
2
|
+
|
|
3
|
+
Deterministic scoring over branch name, PR labels, and commit/title prefixes.
|
|
4
|
+
The verdict is advisory: the Claude Code harness (or the API reasoning layer)
|
|
5
|
+
makes the final call, but the score + evidence give it grounded signal instead
|
|
6
|
+
of guessing.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List, Tuple
|
|
12
|
+
|
|
13
|
+
_BUG_BRANCH = re.compile(r"^(bug|bugfix|fix|hotfix|patch)[/\-_]", re.I)
|
|
14
|
+
_FEAT_BRANCH = re.compile(r"^(feat|feature|enhancement|chore|refactor)[/\-_]", re.I)
|
|
15
|
+
|
|
16
|
+
# Leading [\W_]* tolerates real-world prefixes like "- fix:", "🚀 feat:", ": fixes".
|
|
17
|
+
_BUG_PREFIX = re.compile(r"^[\W_]*(bug\s*)?fix(es|ed)?\b|^[\W_]*hotfix\b|^[\W_]*patch\b", re.I)
|
|
18
|
+
_FEAT_PREFIX = re.compile(r"^[\W_]*(feat|feature|add|implement|introduce|chore|refactor)\b", re.I)
|
|
19
|
+
|
|
20
|
+
_BUG_LABELS = {"bug", "bugfix", "regression", "defect", "hotfix"}
|
|
21
|
+
_FEAT_LABELS = {"feature", "enhancement", "feat", "improvement"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _add(evidence: List[str], score: int, delta: int, msg: str) -> int:
|
|
25
|
+
evidence.append(msg)
|
|
26
|
+
return score + delta
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def classify(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
|
30
|
+
"""Return {verdict, confidence, evidence, score} from a pr_context dict."""
|
|
31
|
+
score = 0 # positive → bugfix, negative → feature
|
|
32
|
+
evidence: List[str] = []
|
|
33
|
+
|
|
34
|
+
branch = ctx.get("head_ref") or ""
|
|
35
|
+
if _BUG_BRANCH.match(branch):
|
|
36
|
+
score = _add(evidence, score, 2, "branch '{}' uses a fix/bug prefix".format(branch))
|
|
37
|
+
elif _FEAT_BRANCH.match(branch):
|
|
38
|
+
score = _add(evidence, score, -2, "branch '{}' uses a feat/feature prefix".format(branch))
|
|
39
|
+
|
|
40
|
+
labels = [str(l).lower() for l in (ctx.get("labels") or [])]
|
|
41
|
+
for lab in labels:
|
|
42
|
+
if lab in _BUG_LABELS:
|
|
43
|
+
score = _add(evidence, score, 3, "PR label '{}' indicates a bug".format(lab))
|
|
44
|
+
elif lab in _FEAT_LABELS:
|
|
45
|
+
score = _add(evidence, score, -3, "PR label '{}' indicates a feature".format(lab))
|
|
46
|
+
|
|
47
|
+
title = ctx.get("title") or ""
|
|
48
|
+
if title:
|
|
49
|
+
if _BUG_PREFIX.search(title):
|
|
50
|
+
score = _add(evidence, score, 2, "PR title '{}' reads like a fix".format(title))
|
|
51
|
+
elif _FEAT_PREFIX.search(title):
|
|
52
|
+
score = _add(evidence, score, -2, "PR title '{}' reads like a feature".format(title))
|
|
53
|
+
|
|
54
|
+
bug_commits = 0
|
|
55
|
+
feat_commits = 0
|
|
56
|
+
for c in ctx.get("commits", []):
|
|
57
|
+
subj = c.get("subject") or ""
|
|
58
|
+
if _BUG_PREFIX.search(subj):
|
|
59
|
+
bug_commits += 1
|
|
60
|
+
elif _FEAT_PREFIX.search(subj):
|
|
61
|
+
feat_commits += 1
|
|
62
|
+
if bug_commits or feat_commits:
|
|
63
|
+
if bug_commits > feat_commits:
|
|
64
|
+
score = _add(evidence, score, 1,
|
|
65
|
+
"{} of {} commit subjects look like fixes".format(
|
|
66
|
+
bug_commits, len(ctx.get("commits", []))))
|
|
67
|
+
elif feat_commits > bug_commits:
|
|
68
|
+
score = _add(evidence, score, -1,
|
|
69
|
+
"{} of {} commit subjects look like features".format(
|
|
70
|
+
feat_commits, len(ctx.get("commits", []))))
|
|
71
|
+
|
|
72
|
+
if score > 0:
|
|
73
|
+
verdict = "bugfix"
|
|
74
|
+
elif score < 0:
|
|
75
|
+
verdict = "feature"
|
|
76
|
+
else:
|
|
77
|
+
verdict = "unknown"
|
|
78
|
+
|
|
79
|
+
# Confidence scales with the margin; capped at a readable 0.95.
|
|
80
|
+
confidence = min(0.95, 0.5 + 0.1 * abs(score)) if verdict != "unknown" else 0.0
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"verdict": verdict,
|
|
84
|
+
"confidence": round(confidence, 2),
|
|
85
|
+
"score": score,
|
|
86
|
+
"evidence": evidence,
|
|
87
|
+
}
|