oneport-impact 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.
- oneport_impact-0.1.0/PKG-INFO +137 -0
- oneport_impact-0.1.0/README.md +121 -0
- oneport_impact-0.1.0/oneport_impact/__init__.py +11 -0
- oneport_impact-0.1.0/oneport_impact/advisor.py +97 -0
- oneport_impact-0.1.0/oneport_impact/analyzer.py +206 -0
- oneport_impact-0.1.0/oneport_impact/callgraph.py +235 -0
- oneport_impact-0.1.0/oneport_impact/cli.py +154 -0
- oneport_impact-0.1.0/oneport_impact/cochange.py +112 -0
- oneport_impact-0.1.0/oneport_impact/config.py +110 -0
- oneport_impact-0.1.0/oneport_impact/exceptions.py +28 -0
- oneport_impact-0.1.0/oneport_impact/formatters/__init__.py +1 -0
- oneport_impact-0.1.0/oneport_impact/formatters/inline.py +116 -0
- oneport_impact-0.1.0/oneport_impact/formatters/json_fmt.py +9 -0
- oneport_impact-0.1.0/oneport_impact/guidelines.py +22 -0
- oneport_impact-0.1.0/oneport_impact/integrations/__init__.py +1 -0
- oneport_impact-0.1.0/oneport_impact/integrations/local_git.py +75 -0
- oneport_impact-0.1.0/oneport_impact/llm.py +70 -0
- oneport_impact-0.1.0/oneport_impact/ownership.py +89 -0
- oneport_impact-0.1.0/oneport_impact/result.py +198 -0
- oneport_impact-0.1.0/oneport_impact.egg-info/PKG-INFO +137 -0
- oneport_impact-0.1.0/oneport_impact.egg-info/SOURCES.txt +25 -0
- oneport_impact-0.1.0/oneport_impact.egg-info/dependency_links.txt +1 -0
- oneport_impact-0.1.0/oneport_impact.egg-info/entry_points.txt +2 -0
- oneport_impact-0.1.0/oneport_impact.egg-info/requires.txt +8 -0
- oneport_impact-0.1.0/oneport_impact.egg-info/top_level.txt +1 -0
- oneport_impact-0.1.0/pyproject.toml +32 -0
- oneport_impact-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oneport-impact
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Blast-radius engine — what breaks if I touch this? Reverse call graph + git co-change mining + ownership, judged by an LLM, run entirely on your machine.
|
|
5
|
+
Author: Oneport
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: click>=8.1.0
|
|
10
|
+
Requires-Dist: rich>=13.0.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
12
|
+
Requires-Dist: httpx>=0.24.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# Oneport Impact
|
|
18
|
+
|
|
19
|
+
**"What breaks if I touch this?" — the blast-radius engine that reads your repo's own structure and history, on your machine.**
|
|
20
|
+
|
|
21
|
+
Before you change a function, one command tells you who calls it, which tests
|
|
22
|
+
cover it, which files *historically change with it*, and who to ask — then an LLM
|
|
23
|
+
judges how load-bearing it is. `grep` finds text; Sourcegraph finds references.
|
|
24
|
+
Neither tells you that `models.py` and `serializers.py` change together **83% of
|
|
25
|
+
the time** even though there's no import between them. That temporal coupling
|
|
26
|
+
lives only in git history — and Impact mines it locally, so nothing is uploaded
|
|
27
|
+
to learn it.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
$ oneport-impact analyze billing/stripe.py
|
|
31
|
+
|
|
32
|
+
Blast radius: billing/stripe.py (file)
|
|
33
|
+
LOAD-BEARING · 128 call site(s) across 34 file(s) · 61 commits in history
|
|
34
|
+
|
|
35
|
+
This is core payment code exercised across the app; a signature change here
|
|
36
|
+
ripples into checkout, webhooks, and the admin refund flow.
|
|
37
|
+
Check first: billing/webhooks.py — changes with this file 83% of the time
|
|
38
|
+
|
|
39
|
+
Changes together with (temporal coupling)
|
|
40
|
+
83% billing/webhooks.py (51/61 commits)
|
|
41
|
+
44% tests/test_payments.py (27/61 commits)
|
|
42
|
+
Ask
|
|
43
|
+
@payments-team [CODEOWNER]
|
|
44
|
+
Priya Nair (22 commits, last 2026-05-30)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Why it's different
|
|
48
|
+
|
|
49
|
+
| Tool | What it gives you | The gap |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `grep` / IDE "find usages" | text matches | no ranking, no history, no judgment |
|
|
52
|
+
| Sourcegraph | cross-repo references | search, not *risk*; needs your code on their servers |
|
|
53
|
+
| **oneport-impact** | callers **+ temporal coupling + ownership + risk verdict** | **local-first — your code never leaves the machine** |
|
|
54
|
+
|
|
55
|
+
Every fact is deterministic (Python `ast` + `git log`); the model only *judges*
|
|
56
|
+
the facts. When a symbol's name is too common to attribute reliably (`__init__`,
|
|
57
|
+
`get`), Impact **refuses to guess** rather than report an inflated number — the
|
|
58
|
+
line between "structured, better than grep" and "confidently wrong."
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install oneport-impact
|
|
64
|
+
# optional, for the risk verdict — detection works without it:
|
|
65
|
+
export GEMINI_API_KEY=AIza... # free at https://aistudio.google.com/apikey
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Use
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Query the blast radius of a symbol or file
|
|
72
|
+
oneport-impact analyze charge # by name
|
|
73
|
+
oneport-impact analyze billing.stripe.Client.charge # by qualname
|
|
74
|
+
oneport-impact analyze billing/stripe.py # by file
|
|
75
|
+
|
|
76
|
+
# Gate a change set (for pre-commit / CI)
|
|
77
|
+
oneport-impact check --staged # what you're about to commit
|
|
78
|
+
oneport-impact check --head # the last commit
|
|
79
|
+
oneport-impact check --staged --format json # machine-readable
|
|
80
|
+
|
|
81
|
+
oneport-impact analyze charge --no-llm # facts only, no API key
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Exit code is `1` when a change trips a blocking finding (per `--fail-on`),
|
|
85
|
+
`0` otherwise — drop `check` straight into CI.
|
|
86
|
+
|
|
87
|
+
### What the gate flags
|
|
88
|
+
|
|
89
|
+
- **IMP001 — wide blast radius:** you're changing a symbol many places call.
|
|
90
|
+
- **IMP010 — missing co-change companion:** you changed a file but *not* the file
|
|
91
|
+
it historically changes with, e.g. *"you changed `model.py` but not
|
|
92
|
+
`serializer.py`, which changes with it 80% of the time."*
|
|
93
|
+
|
|
94
|
+
## Configure — `.oneportrc`
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
thresholds:
|
|
98
|
+
fan_in_warn: 8 # callers before a change is "wide"
|
|
99
|
+
fan_in_error: 25 # …before it's "very wide"
|
|
100
|
+
cochange_confidence: 0.5 # a companion must co-change this often to be flagged
|
|
101
|
+
cochange_min_together: 3 # …and at least this many times
|
|
102
|
+
max_commits: 1500 # history depth mined for co-change
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The same `.oneport/guidelines.md` the rest of the suite reads is fed to the risk
|
|
106
|
+
verdict as context ("our payments module is business-critical").
|
|
107
|
+
|
|
108
|
+
## In the Oneport gate
|
|
109
|
+
|
|
110
|
+
`oneport-impact` is an opt-in gate in [`op ship`](https://pypi.org/project/oneport/).
|
|
111
|
+
Enable it in `.oneport/oneport.yaml`:
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
enable:
|
|
115
|
+
- impact
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It's advisory by default (warns, blocks only on critical) — it informs the
|
|
119
|
+
ship decision without nagging.
|
|
120
|
+
|
|
121
|
+
## Privacy
|
|
122
|
+
|
|
123
|
+
Serverless by design. The call graph, co-change mining, and ownership all run on
|
|
124
|
+
your machine from your repo's own `.git`. Only the optional one-paragraph risk
|
|
125
|
+
verdict calls a model, on your key. There is no Oneport server; your code never
|
|
126
|
+
leaves the building.
|
|
127
|
+
|
|
128
|
+
## How it works
|
|
129
|
+
|
|
130
|
+
- **`callgraph.py`** — reverse call graph via the `ast` module, with an ambiguity
|
|
131
|
+
guard so common names don't inflate fan-in.
|
|
132
|
+
- **`cochange.py`** — temporal-coupling miner over `git log`, skipping mass-change
|
|
133
|
+
commits that would couple everything.
|
|
134
|
+
- **`ownership.py`** — git authors + CODEOWNERS.
|
|
135
|
+
- **`advisor.py`** — the LLM risk verdict; judgment only, never invents facts.
|
|
136
|
+
|
|
137
|
+
MIT licensed.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Oneport Impact
|
|
2
|
+
|
|
3
|
+
**"What breaks if I touch this?" — the blast-radius engine that reads your repo's own structure and history, on your machine.**
|
|
4
|
+
|
|
5
|
+
Before you change a function, one command tells you who calls it, which tests
|
|
6
|
+
cover it, which files *historically change with it*, and who to ask — then an LLM
|
|
7
|
+
judges how load-bearing it is. `grep` finds text; Sourcegraph finds references.
|
|
8
|
+
Neither tells you that `models.py` and `serializers.py` change together **83% of
|
|
9
|
+
the time** even though there's no import between them. That temporal coupling
|
|
10
|
+
lives only in git history — and Impact mines it locally, so nothing is uploaded
|
|
11
|
+
to learn it.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ oneport-impact analyze billing/stripe.py
|
|
15
|
+
|
|
16
|
+
Blast radius: billing/stripe.py (file)
|
|
17
|
+
LOAD-BEARING · 128 call site(s) across 34 file(s) · 61 commits in history
|
|
18
|
+
|
|
19
|
+
This is core payment code exercised across the app; a signature change here
|
|
20
|
+
ripples into checkout, webhooks, and the admin refund flow.
|
|
21
|
+
Check first: billing/webhooks.py — changes with this file 83% of the time
|
|
22
|
+
|
|
23
|
+
Changes together with (temporal coupling)
|
|
24
|
+
83% billing/webhooks.py (51/61 commits)
|
|
25
|
+
44% tests/test_payments.py (27/61 commits)
|
|
26
|
+
Ask
|
|
27
|
+
@payments-team [CODEOWNER]
|
|
28
|
+
Priya Nair (22 commits, last 2026-05-30)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Why it's different
|
|
32
|
+
|
|
33
|
+
| Tool | What it gives you | The gap |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `grep` / IDE "find usages" | text matches | no ranking, no history, no judgment |
|
|
36
|
+
| Sourcegraph | cross-repo references | search, not *risk*; needs your code on their servers |
|
|
37
|
+
| **oneport-impact** | callers **+ temporal coupling + ownership + risk verdict** | **local-first — your code never leaves the machine** |
|
|
38
|
+
|
|
39
|
+
Every fact is deterministic (Python `ast` + `git log`); the model only *judges*
|
|
40
|
+
the facts. When a symbol's name is too common to attribute reliably (`__init__`,
|
|
41
|
+
`get`), Impact **refuses to guess** rather than report an inflated number — the
|
|
42
|
+
line between "structured, better than grep" and "confidently wrong."
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install oneport-impact
|
|
48
|
+
# optional, for the risk verdict — detection works without it:
|
|
49
|
+
export GEMINI_API_KEY=AIza... # free at https://aistudio.google.com/apikey
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Use
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Query the blast radius of a symbol or file
|
|
56
|
+
oneport-impact analyze charge # by name
|
|
57
|
+
oneport-impact analyze billing.stripe.Client.charge # by qualname
|
|
58
|
+
oneport-impact analyze billing/stripe.py # by file
|
|
59
|
+
|
|
60
|
+
# Gate a change set (for pre-commit / CI)
|
|
61
|
+
oneport-impact check --staged # what you're about to commit
|
|
62
|
+
oneport-impact check --head # the last commit
|
|
63
|
+
oneport-impact check --staged --format json # machine-readable
|
|
64
|
+
|
|
65
|
+
oneport-impact analyze charge --no-llm # facts only, no API key
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Exit code is `1` when a change trips a blocking finding (per `--fail-on`),
|
|
69
|
+
`0` otherwise — drop `check` straight into CI.
|
|
70
|
+
|
|
71
|
+
### What the gate flags
|
|
72
|
+
|
|
73
|
+
- **IMP001 — wide blast radius:** you're changing a symbol many places call.
|
|
74
|
+
- **IMP010 — missing co-change companion:** you changed a file but *not* the file
|
|
75
|
+
it historically changes with, e.g. *"you changed `model.py` but not
|
|
76
|
+
`serializer.py`, which changes with it 80% of the time."*
|
|
77
|
+
|
|
78
|
+
## Configure — `.oneportrc`
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
thresholds:
|
|
82
|
+
fan_in_warn: 8 # callers before a change is "wide"
|
|
83
|
+
fan_in_error: 25 # …before it's "very wide"
|
|
84
|
+
cochange_confidence: 0.5 # a companion must co-change this often to be flagged
|
|
85
|
+
cochange_min_together: 3 # …and at least this many times
|
|
86
|
+
max_commits: 1500 # history depth mined for co-change
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The same `.oneport/guidelines.md` the rest of the suite reads is fed to the risk
|
|
90
|
+
verdict as context ("our payments module is business-critical").
|
|
91
|
+
|
|
92
|
+
## In the Oneport gate
|
|
93
|
+
|
|
94
|
+
`oneport-impact` is an opt-in gate in [`op ship`](https://pypi.org/project/oneport/).
|
|
95
|
+
Enable it in `.oneport/oneport.yaml`:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
enable:
|
|
99
|
+
- impact
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It's advisory by default (warns, blocks only on critical) — it informs the
|
|
103
|
+
ship decision without nagging.
|
|
104
|
+
|
|
105
|
+
## Privacy
|
|
106
|
+
|
|
107
|
+
Serverless by design. The call graph, co-change mining, and ownership all run on
|
|
108
|
+
your machine from your repo's own `.git`. Only the optional one-paragraph risk
|
|
109
|
+
verdict calls a model, on your key. There is no Oneport server; your code never
|
|
110
|
+
leaves the building.
|
|
111
|
+
|
|
112
|
+
## How it works
|
|
113
|
+
|
|
114
|
+
- **`callgraph.py`** — reverse call graph via the `ast` module, with an ambiguity
|
|
115
|
+
guard so common names don't inflate fan-in.
|
|
116
|
+
- **`cochange.py`** — temporal-coupling miner over `git log`, skipping mass-change
|
|
117
|
+
commits that would couple everything.
|
|
118
|
+
- **`ownership.py`** — git authors + CODEOWNERS.
|
|
119
|
+
- **`advisor.py`** — the LLM risk verdict; judgment only, never invents facts.
|
|
120
|
+
|
|
121
|
+
MIT licensed.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
oneport-impact — the blast-radius engine.
|
|
3
|
+
|
|
4
|
+
Answers "what breaks if I touch this?" from the repo's own structure and history:
|
|
5
|
+
a reverse call graph (who calls it), git co-change mining (what moves with it), and
|
|
6
|
+
ownership (who to ask) — judged by an LLM, run entirely on your machine.
|
|
7
|
+
|
|
8
|
+
Part of the Oneport developer OS. Detection is deterministic; the model only judges.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The risk-verdict layer — judgment, and only judgment.
|
|
3
|
+
|
|
4
|
+
Input to the model: the deterministic facts about one target (fan-in, the caller
|
|
5
|
+
files, the co-change partners with confidences, the owners) plus optional team
|
|
6
|
+
guidelines. Output: a short plain-English risk read and the single consumer most
|
|
7
|
+
worth checking. The model never adds a caller or partner — if it names something
|
|
8
|
+
not in the facts, we ignore it.
|
|
9
|
+
|
|
10
|
+
Skipped cleanly (leaving facts-only output) when there's no key or --no-llm.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from oneport_impact.config import Config
|
|
19
|
+
from oneport_impact.exceptions import ImpactError
|
|
20
|
+
from oneport_impact.llm import call_llm
|
|
21
|
+
from oneport_impact.result import BlastRadius
|
|
22
|
+
|
|
23
|
+
SYSTEM_PROMPT = """\
|
|
24
|
+
You are a staff engineer assessing the blast radius of a proposed code change.
|
|
25
|
+
You are given ONLY deterministic facts already computed from the repository:
|
|
26
|
+
the target symbol/file, how many places call it (fan-in), the files that call it,
|
|
27
|
+
the files that historically change together with it (temporal coupling, with a
|
|
28
|
+
confidence %), and its owners.
|
|
29
|
+
|
|
30
|
+
Do NOT invent callers, files, partners, or owners — reason only from the facts
|
|
31
|
+
given. Judge how risky it is to change this target, and name the SINGLE consumer
|
|
32
|
+
or coupled file most worth checking before the change ships.
|
|
33
|
+
|
|
34
|
+
Reply with ONLY this JSON (no prose, no fences):
|
|
35
|
+
{"verdict": "<2-3 sentences: how load-bearing is this, and why>",
|
|
36
|
+
"riskiest": "<one file or caller to check first, path only>",
|
|
37
|
+
"reason": "<one line: why that one>"}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _facts_block(br: BlastRadius, guidelines: str) -> str:
|
|
42
|
+
lines = [
|
|
43
|
+
f"Target: {br.target} ({br.target_kind})",
|
|
44
|
+
f"Fan-in: {br.fan_in} call site(s) across {br.caller_files} file(s)",
|
|
45
|
+
f"History: the target file(s) changed in {br.target_commits} commit(s)",
|
|
46
|
+
"",
|
|
47
|
+
"Top calling files:",
|
|
48
|
+
]
|
|
49
|
+
caller_files: dict[str, int] = {}
|
|
50
|
+
for c in br.callers:
|
|
51
|
+
caller_files[c.file] = caller_files.get(c.file, 0) + 1
|
|
52
|
+
for f, n in sorted(caller_files.items(), key=lambda x: -x[1])[:10]:
|
|
53
|
+
lines.append(f" - {f} ({n} call site(s))")
|
|
54
|
+
if not caller_files:
|
|
55
|
+
lines.append(" (none found — no static callers)")
|
|
56
|
+
|
|
57
|
+
lines += ["", "Historically co-changed files (temporal coupling):"]
|
|
58
|
+
for p in br.partners[:10]:
|
|
59
|
+
lines.append(f" - {p.path} ({p.pct}% of the target's commits, {p.together}/{p.target_commits})")
|
|
60
|
+
if not br.partners:
|
|
61
|
+
lines.append(" (none)")
|
|
62
|
+
|
|
63
|
+
if br.owners:
|
|
64
|
+
lines += ["", "Owners: " + ", ".join(
|
|
65
|
+
(o.name + (" [CODEOWNER]" if o.codeowner else "")) for o in br.owners)]
|
|
66
|
+
if guidelines:
|
|
67
|
+
lines += ["", "Team guidelines (context; do not change the JSON schema):", guidelines[:1500]]
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse(text: str) -> dict:
|
|
72
|
+
cleaned = re.sub(r"^```(?:json)?\s*", "", text.strip())
|
|
73
|
+
cleaned = re.sub(r"\s*```$", "", cleaned)
|
|
74
|
+
m = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
|
75
|
+
if m:
|
|
76
|
+
cleaned = m.group(0)
|
|
77
|
+
data = json.loads(cleaned)
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
raise ValueError("expected a JSON object")
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assess(br: BlastRadius, config: Config, guidelines: str = "") -> int:
|
|
84
|
+
"""Fill br.verdict/riskiest/reason in place. Returns tokens used (0 on skip)."""
|
|
85
|
+
if not config.api_key:
|
|
86
|
+
return 0
|
|
87
|
+
user = _facts_block(br, guidelines)
|
|
88
|
+
try:
|
|
89
|
+
text, tokens = call_llm(config.model, config.api_key, SYSTEM_PROMPT, user, config.max_tokens)
|
|
90
|
+
data = _parse(text)
|
|
91
|
+
except (ImpactError, ValueError, json.JSONDecodeError) as exc:
|
|
92
|
+
br.reason = f"(risk verdict unavailable: {str(exc)[:120]})"
|
|
93
|
+
return 0
|
|
94
|
+
br.verdict = str(data.get("verdict", "")).strip()
|
|
95
|
+
br.riskiest = str(data.get("riskiest", "")).strip()
|
|
96
|
+
br.reason = str(data.get("reason", "")).strip()
|
|
97
|
+
return tokens
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The orchestrator: run the deterministic engines, assemble a BlastRadius, and — in
|
|
3
|
+
gate mode — distil findings whose JSON matches the Oneport contract so `op ship`
|
|
4
|
+
can merge impact with the other gates.
|
|
5
|
+
|
|
6
|
+
Order per target: call graph (who calls it) + co-change (what moves with it) +
|
|
7
|
+
ownership (who to ask) → optional LLM risk verdict. Facts first, judgment last.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from oneport_impact import advisor
|
|
16
|
+
from oneport_impact.callgraph import CallGraph, build_call_graph
|
|
17
|
+
from oneport_impact.cochange import CoChangeIndex, build_cochange_index
|
|
18
|
+
from oneport_impact.config import Config
|
|
19
|
+
from oneport_impact.exceptions import ResolveError
|
|
20
|
+
from oneport_impact.guidelines import load_guidelines
|
|
21
|
+
from oneport_impact.integrations import local_git
|
|
22
|
+
from oneport_impact.ownership import owners_of
|
|
23
|
+
from oneport_impact.result import (
|
|
24
|
+
BlastRadius, CallSite, Finding, ImpactReport, Severity, Symbol, TestRef,
|
|
25
|
+
compute_blocking,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_TEST_HINTS = ("test_", "_test", "/tests/", "tests/", "conftest", "/testing/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_test_path(path: str) -> bool:
|
|
32
|
+
p = path.replace("\\", "/").lower()
|
|
33
|
+
base = p.rsplit("/", 1)[-1]
|
|
34
|
+
return ("/tests/" in p or p.startswith("tests/") or "/testing/" in p
|
|
35
|
+
or base.startswith("test_") or base.endswith("_test.py") or "conftest" in base)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Analyzer:
|
|
39
|
+
def __init__(self, config: Config, root: str | Path = ".") -> None:
|
|
40
|
+
self.config = config
|
|
41
|
+
self.root = Path(root).resolve()
|
|
42
|
+
self._graph: CallGraph | None = None
|
|
43
|
+
self._cochange: CoChangeIndex | None = None
|
|
44
|
+
self._guidelines = load_guidelines(config.guidelines_path)
|
|
45
|
+
self._has_git = local_git.is_repo(self.root)
|
|
46
|
+
|
|
47
|
+
# ── shared, lazily-built indexes ─────────────────────────────────────────
|
|
48
|
+
@property
|
|
49
|
+
def graph(self) -> CallGraph:
|
|
50
|
+
if self._graph is None:
|
|
51
|
+
self._graph = build_call_graph(self.root)
|
|
52
|
+
return self._graph
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def cochange(self) -> CoChangeIndex:
|
|
56
|
+
if self._cochange is None:
|
|
57
|
+
self._cochange = (
|
|
58
|
+
build_cochange_index(self.root, max_commits=self.config.thresholds.max_commits)
|
|
59
|
+
if self._has_git else CoChangeIndex()
|
|
60
|
+
)
|
|
61
|
+
return self._cochange
|
|
62
|
+
|
|
63
|
+
# ── analyze mode ─────────────────────────────────────────────────────────
|
|
64
|
+
def analyze(self, query: str, use_llm: bool = True) -> ImpactReport:
|
|
65
|
+
start = time.monotonic()
|
|
66
|
+
report = ImpactReport(mode="analyze", target=query, model=self.config.model)
|
|
67
|
+
|
|
68
|
+
symbols = self.graph.resolve(query)
|
|
69
|
+
as_file = self._as_file(query)
|
|
70
|
+
if as_file and not (symbols and not as_file):
|
|
71
|
+
file_syms = self.graph.symbols_in_file(as_file)
|
|
72
|
+
br = self._blast_for_symbols(as_file, "file", file_syms, file_scope=as_file)
|
|
73
|
+
elif symbols:
|
|
74
|
+
br = self._blast_for_symbols(query, "symbol", symbols)
|
|
75
|
+
else:
|
|
76
|
+
raise ResolveError(
|
|
77
|
+
f"Could not find '{query}' as a symbol or a file in {self.root.name}. "
|
|
78
|
+
"Try a function/class name, a dotted qualname, or a repo-relative path."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
skipped = self._ambiguous_skipped(br)
|
|
82
|
+
if skipped:
|
|
83
|
+
report.notes.append(
|
|
84
|
+
f"{skipped} symbol(s) here have names too common to attribute callers "
|
|
85
|
+
"reliably (e.g. __init__, get); their call sites are excluded to keep "
|
|
86
|
+
"fan-in honest.")
|
|
87
|
+
if not self._has_git:
|
|
88
|
+
report.notes.append("Not a git repo — co-change and ownership were skipped.")
|
|
89
|
+
if use_llm and self.config.api_key:
|
|
90
|
+
report.total_tokens += advisor.assess(br, self.config, self._guidelines)
|
|
91
|
+
|
|
92
|
+
report.blast_radii = [br]
|
|
93
|
+
report.elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
94
|
+
return report
|
|
95
|
+
|
|
96
|
+
# ── check (gate) mode ────────────────────────────────────────────────────
|
|
97
|
+
def check(self, mode: str, use_llm: bool = True, fail_on: Severity = Severity.ERROR) -> ImpactReport:
|
|
98
|
+
start = time.monotonic()
|
|
99
|
+
report = ImpactReport(mode="check", target=mode, model=self.config.model)
|
|
100
|
+
local_git.require_repo(self.root)
|
|
101
|
+
|
|
102
|
+
changed = [f for f in local_git.changed_files(self.root, mode)]
|
|
103
|
+
changed_set = set(changed)
|
|
104
|
+
if not changed:
|
|
105
|
+
report.notes.append(f"No {mode} changes to analyze.")
|
|
106
|
+
report.elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
107
|
+
return report
|
|
108
|
+
|
|
109
|
+
th = self.config.thresholds
|
|
110
|
+
radii: list[BlastRadius] = []
|
|
111
|
+
for f in changed:
|
|
112
|
+
if not (self.root / f).exists():
|
|
113
|
+
continue # deleted file
|
|
114
|
+
file_syms = self.graph.symbols_in_file(f) if f.endswith(".py") else []
|
|
115
|
+
br = self._blast_for_symbols(f, "file", file_syms, file_scope=f)
|
|
116
|
+
radii.append(br)
|
|
117
|
+
|
|
118
|
+
# 1) wide blast radius
|
|
119
|
+
if br.fan_in >= th.fan_in_error:
|
|
120
|
+
report.findings.append(Finding(
|
|
121
|
+
severity=Severity.ERROR, file=f, line=1, rule_id="IMP001",
|
|
122
|
+
message=(f"Changing {f} affects {br.fan_in} call sites across "
|
|
123
|
+
f"{br.caller_files} files — very wide blast radius."),
|
|
124
|
+
fix="Split the change, add a compatibility shim, or land it behind a flag; "
|
|
125
|
+
"review the top callers before merging."))
|
|
126
|
+
elif br.fan_in >= th.fan_in_warn:
|
|
127
|
+
report.findings.append(Finding(
|
|
128
|
+
severity=Severity.WARNING, file=f, line=1, rule_id="IMP001",
|
|
129
|
+
message=(f"Changing {f} affects {br.fan_in} call sites across "
|
|
130
|
+
f"{br.caller_files} files — wide blast radius."),
|
|
131
|
+
fix="Check the callers listed in the analysis before merging."))
|
|
132
|
+
|
|
133
|
+
# 2) companions you didn't touch
|
|
134
|
+
for p in br.partners:
|
|
135
|
+
if (p.confidence >= th.cochange_confidence and p.together >= th.cochange_min_together
|
|
136
|
+
and p.path not in changed_set):
|
|
137
|
+
report.findings.append(Finding(
|
|
138
|
+
severity=Severity.WARNING, file=f, line=1, rule_id="IMP010",
|
|
139
|
+
message=(f"You changed {f} but not {p.path}, which changes with it "
|
|
140
|
+
f"{p.pct}% of the time ({p.together}/{p.target_commits} commits)."),
|
|
141
|
+
fix=f"Confirm {p.path} doesn't also need updating for this change."))
|
|
142
|
+
|
|
143
|
+
# LLM verdict on the single widest-radius file (cheap; enriches the report).
|
|
144
|
+
if use_llm and self.config.api_key and radii:
|
|
145
|
+
widest = max(radii, key=lambda b: b.fan_in)
|
|
146
|
+
if widest.fan_in > 0:
|
|
147
|
+
report.total_tokens += advisor.assess(widest, self.config, self._guidelines)
|
|
148
|
+
|
|
149
|
+
report.findings.sort(key=lambda f: (-f.severity.rank, f.file))
|
|
150
|
+
report.blast_radii = radii
|
|
151
|
+
report.blocking = compute_blocking(report.findings, fail_on)
|
|
152
|
+
report.elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
153
|
+
return report
|
|
154
|
+
|
|
155
|
+
# ── core: assemble a BlastRadius from the engines ────────────────────────
|
|
156
|
+
def _blast_for_symbols(
|
|
157
|
+
self, target: str, kind: str, symbols: list[Symbol], file_scope: str | None = None,
|
|
158
|
+
) -> BlastRadius:
|
|
159
|
+
# De-dupe callers by (file,line,called); a call site inside the target's own
|
|
160
|
+
# file is internal, not external blast radius.
|
|
161
|
+
seen: set[tuple] = set()
|
|
162
|
+
callers: list[CallSite] = []
|
|
163
|
+
for sym in symbols:
|
|
164
|
+
for c in self.graph.callers_of(sym):
|
|
165
|
+
if file_scope and c.file == file_scope:
|
|
166
|
+
continue
|
|
167
|
+
key = (c.file, c.line, c.called)
|
|
168
|
+
if key in seen:
|
|
169
|
+
continue
|
|
170
|
+
seen.add(key)
|
|
171
|
+
callers.append(c)
|
|
172
|
+
|
|
173
|
+
# co-change + ownership key off the file(s) involved.
|
|
174
|
+
files = sorted({s.file for s in symbols} or ({file_scope} if file_scope else set()))
|
|
175
|
+
partners = []
|
|
176
|
+
target_commits = 0
|
|
177
|
+
owners = []
|
|
178
|
+
if self._has_git and files:
|
|
179
|
+
primary = file_scope or files[0]
|
|
180
|
+
partners = self.cochange.partners(
|
|
181
|
+
primary, min_together=2, top=12)
|
|
182
|
+
target_commits = self.cochange.target_commit_count(primary)
|
|
183
|
+
owners = owners_of(self.root, primary, top=4)
|
|
184
|
+
|
|
185
|
+
tests = [
|
|
186
|
+
TestRef(test=c.caller_qualname, file=c.file, line=c.line)
|
|
187
|
+
for c in callers if is_test_path(c.file)
|
|
188
|
+
]
|
|
189
|
+
non_test_callers = [c for c in callers if not is_test_path(c.file)]
|
|
190
|
+
|
|
191
|
+
return BlastRadius(
|
|
192
|
+
target=target, target_kind=kind, symbols=symbols,
|
|
193
|
+
callers=non_test_callers, tests=tests, partners=partners, owners=owners,
|
|
194
|
+
fan_in=len(non_test_callers),
|
|
195
|
+
caller_files=len({c.file for c in non_test_callers}),
|
|
196
|
+
target_commits=target_commits,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _ambiguous_skipped(self, br: BlastRadius) -> int:
|
|
200
|
+
return sum(1 for s in br.symbols if not self.graph.attributable(s))
|
|
201
|
+
|
|
202
|
+
def _as_file(self, query: str) -> str | None:
|
|
203
|
+
rel = query.replace("\\", "/")
|
|
204
|
+
if (self.root / rel).is_file() and rel.endswith(".py"):
|
|
205
|
+
return rel
|
|
206
|
+
return None
|