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.
Files changed (27) hide show
  1. oneport_impact-0.1.0/PKG-INFO +137 -0
  2. oneport_impact-0.1.0/README.md +121 -0
  3. oneport_impact-0.1.0/oneport_impact/__init__.py +11 -0
  4. oneport_impact-0.1.0/oneport_impact/advisor.py +97 -0
  5. oneport_impact-0.1.0/oneport_impact/analyzer.py +206 -0
  6. oneport_impact-0.1.0/oneport_impact/callgraph.py +235 -0
  7. oneport_impact-0.1.0/oneport_impact/cli.py +154 -0
  8. oneport_impact-0.1.0/oneport_impact/cochange.py +112 -0
  9. oneport_impact-0.1.0/oneport_impact/config.py +110 -0
  10. oneport_impact-0.1.0/oneport_impact/exceptions.py +28 -0
  11. oneport_impact-0.1.0/oneport_impact/formatters/__init__.py +1 -0
  12. oneport_impact-0.1.0/oneport_impact/formatters/inline.py +116 -0
  13. oneport_impact-0.1.0/oneport_impact/formatters/json_fmt.py +9 -0
  14. oneport_impact-0.1.0/oneport_impact/guidelines.py +22 -0
  15. oneport_impact-0.1.0/oneport_impact/integrations/__init__.py +1 -0
  16. oneport_impact-0.1.0/oneport_impact/integrations/local_git.py +75 -0
  17. oneport_impact-0.1.0/oneport_impact/llm.py +70 -0
  18. oneport_impact-0.1.0/oneport_impact/ownership.py +89 -0
  19. oneport_impact-0.1.0/oneport_impact/result.py +198 -0
  20. oneport_impact-0.1.0/oneport_impact.egg-info/PKG-INFO +137 -0
  21. oneport_impact-0.1.0/oneport_impact.egg-info/SOURCES.txt +25 -0
  22. oneport_impact-0.1.0/oneport_impact.egg-info/dependency_links.txt +1 -0
  23. oneport_impact-0.1.0/oneport_impact.egg-info/entry_points.txt +2 -0
  24. oneport_impact-0.1.0/oneport_impact.egg-info/requires.txt +8 -0
  25. oneport_impact-0.1.0/oneport_impact.egg-info/top_level.txt +1 -0
  26. oneport_impact-0.1.0/pyproject.toml +32 -0
  27. 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