Skip to content

Commit d546232

Browse files
committed
feat: add import graph analysis
Made-with: Cursor
1 parent d7ed783 commit d546232

10 files changed

Lines changed: 488 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [0.1.2] — 2026-04-05
4+
5+
### Added
6+
7+
- Python import graph: `build_import_graph()`, `expand_with_imports()`
8+
- `ContextEngine(use_import_graph=..., import_graph_depth=...)`
9+
- `ContextBuilder.use_import_graph(depth=...)` and `no_import_graph()`
10+
- CLI: `--import-graph` / `--no-import-graph`, `--import-graph-depth N`
11+
312
## [0.1.1] — 2026-04-05
413

514
### Added

README.md

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,60 @@ patterns = parse_ctxengignore(Path("."))
168168
# → list of pattern strings, or [] if no file
169169
```
170170

171+
### Import graph (Python)
172+
173+
After files are scored, **ctxeng** parses static ``import`` / ``from … import`` statements in each discovered ``.py`` file, resolves **relative imports** from the file’s location, and can **pull in imported modules** from the same collection set before the token budget is applied.
174+
175+
- **Default:** one hop (`import_graph_depth=1`), relevance for added files = parent score × **0.7**
176+
- **Edges only** to files already in the current discovery set (filesystem / git / explicit list)
177+
- Stdlib and third-party imports are ignored (no file under your root → no edge)
178+
179+
```python
180+
from ctxeng import ContextEngine, ContextBuilder
181+
182+
# Engine: on by default; adjust depth or turn off
183+
engine = ContextEngine(
184+
root=".",
185+
use_import_graph=True,
186+
import_graph_depth=2,
187+
)
188+
189+
ctx = (
190+
ContextBuilder(".")
191+
.for_model("claude-sonnet-4")
192+
.use_import_graph(depth=2) # follow two hops of local imports
193+
# .no_import_graph() # disable expansion
194+
.build("Fix the checkout bug in orders")
195+
)
196+
```
197+
198+
CLI (import expansion is **on** by default):
199+
200+
```bash
201+
ctxeng build "Refactor auth" --no-import-graph
202+
ctxeng build "Refactor auth" --import-graph-depth 2
203+
```
204+
205+
Lower-level API:
206+
207+
```python
208+
from pathlib import Path
209+
from ctxeng import build_import_graph, expand_with_imports
210+
from ctxeng.models import ContextFile
211+
212+
paths = [Path("src/app.py"), Path("src/lib.py")]
213+
graph = build_import_graph(Path("."), paths)
214+
# graph[path] → list of imported paths (within `paths`)
215+
216+
expanded = expand_with_imports(
217+
[ContextFile(path=paths[0], content="...", relevance_score=0.9, language="python")],
218+
graph,
219+
Path("."),
220+
max_depth=1,
221+
score_decay=0.7,
222+
)
223+
```
224+
171225
---
172226

173227
## How It Works
@@ -179,7 +233,7 @@ src/auth/login.py ─┐
179233
src/models/user.py ─┤ 1. Score files 2. Fit budget <context>
180234
src/api/routes.py ─┼─► vs query + git ─► smart truncate ─► <file>...</file>
181235
tests/test_auth.py ─┤ recency + AST token-aware <file>...</file>
182-
...500 more files ─┘ </context>
236+
...500 more files ─┘ (+ local Python import expansion)
183237
```
184238

185239
### Scoring signals
@@ -192,6 +246,7 @@ Each file gets a relevance score from 0 → 1, combining:
192246
| **AST symbols** | Class/function/import names that match the query (Python) |
193247
| **Path relevance** | Filename and directory names matching query tokens |
194248
| **Git recency** | Files touched in recent commits score higher |
249+
| **Import expansion** | After scoring, locally imported Python modules can be added with a decayed score |
195250

196251
### Token budget optimization
197252

@@ -270,6 +325,8 @@ ContextEngine(
270325
include_patterns=None, # ["**/*.py"] — only these files
271326
exclude_patterns=None, # ["tests/**"] — skip these
272327
use_git=True, # Use git recency signal
328+
use_import_graph=True, # Add local Python imports of scored files
329+
import_graph_depth=1, # Hops along the import graph
273330
)
274331
```
275332

@@ -298,6 +355,7 @@ ContextBuilder(root=".")
298355
.with_system("You are an expert Python engineer.")
299356
.max_file_size(200) # KB
300357
.no_git()
358+
.use_import_graph(depth=2) # optional; omit for default depth 1
301359
.build("query")
302360
# → Context
303361
```
@@ -346,6 +404,10 @@ build options:
346404
--budget Override total token budget
347405
--no-git Disable git recency scoring
348406
--max-size Max file size in KB (default: 500)
407+
--import-graph / --no-import-graph
408+
Expand with local Python import graph (default: on)
409+
--import-graph-depth N
410+
Import hops when import graph is on (default: 1)
349411
```
350412

351413
---
@@ -382,9 +444,9 @@ You could. But you'll hit these problems immediately:
382444
- [ ] Semantic similarity scoring
383445
- [ ] `ctxeng watch` — auto-rebuild on file changes
384446
- [ ] VSCode extension
385-
- [ ] Import graph analysis
386447
- [ ] Cost estimates
387448
- [ ] Streaming context into LLM APIs
449+
- [x] Import graph analysis (local Python static imports) ✅
388450
- [x] `.ctxengignore` file support ✅
389451

390452
---

ctxeng/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
from ctxeng.builder import ContextBuilder
77
from ctxeng.core import ContextEngine
88
from ctxeng.ignore import parse_ctxengignore
9+
from ctxeng.import_graph import build_import_graph, expand_with_imports
910
from ctxeng.models import Context, ContextFile, TokenBudget
1011

11-
__version__ = "0.1.1"
12+
__version__ = "0.1.2"
1213
__all__ = [
1314
"ContextEngine",
1415
"ContextBuilder",
1516
"Context",
1617
"ContextFile",
1718
"TokenBudget",
1819
"parse_ctxengignore",
20+
"build_import_graph",
21+
"expand_with_imports",
1922
]

ctxeng/builder.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def __init__(self, root: str | Path = ".") -> None:
4040
self._system = ""
4141
self._max_file_size_kb = 500
4242
self._use_git = True
43+
self._use_import_graph = True
44+
self._import_graph_depth = 1
4345

4446
def for_model(self, model: str) -> ContextBuilder:
4547
"""Set the target model (determines token budget)."""
@@ -87,6 +89,17 @@ def no_git(self) -> ContextBuilder:
8789
self._use_git = False
8890
return self
8991

92+
def use_import_graph(self, depth: int = 1) -> ContextBuilder:
93+
"""Follow local Python imports from scored files (default depth 1)."""
94+
self._use_import_graph = True
95+
self._import_graph_depth = depth
96+
return self
97+
98+
def no_import_graph(self) -> ContextBuilder:
99+
"""Disable import-graph expansion."""
100+
self._use_import_graph = False
101+
return self
102+
90103
def build(self, query: str = "") -> Context:
91104
"""
92105
Build and return the optimized Context.
@@ -105,6 +118,8 @@ def build(self, query: str = "") -> Context:
105118
include_patterns=self._include or None,
106119
exclude_patterns=self._exclude or None,
107120
use_git=self._use_git,
121+
use_import_graph=self._use_import_graph,
122+
import_graph_depth=self._import_graph_depth,
108123
)
109124
return engine.build(
110125
query=query,

ctxeng/cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def cmd_build(args: argparse.Namespace) -> None:
3737
builder = builder.with_system(args.system)
3838
if args.no_git:
3939
builder = builder.no_git()
40+
if not args.import_graph:
41+
builder = builder.no_import_graph()
42+
else:
43+
builder = builder.use_import_graph(depth=args.import_graph_depth)
4044
if args.budget:
4145
builder = builder.with_budget(args.budget)
4246

@@ -138,6 +142,19 @@ def main() -> None:
138142
help="Override token budget total")
139143
build_p.add_argument("--max-size", type=int, default=500,
140144
help="Max file size in KB (default: 500)")
145+
build_p.add_argument(
146+
"--import-graph",
147+
action=argparse.BooleanOptionalAction,
148+
default=True,
149+
help="Expand context using local Python import graph (default: on)",
150+
)
151+
build_p.add_argument(
152+
"--import-graph-depth",
153+
type=int,
154+
default=1,
155+
metavar="N",
156+
help="Import hops when --import-graph is on (default: 1)",
157+
)
141158
build_p.set_defaults(func=cmd_build)
142159

143160
# --- info ---

ctxeng/core.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from pathlib import Path
66

7+
from ctxeng.import_graph import build_import_graph, expand_with_imports
78
from ctxeng.models import Context, ContextFile, TokenBudget
89
from ctxeng.optimizer import count_tokens, detect_language, optimize_budget
910
from ctxeng.scorer import rank_files
@@ -31,6 +32,8 @@ class ContextEngine:
3132
include_patterns: Only include files matching these glob patterns.
3233
exclude_patterns: Skip files matching these glob patterns.
3334
use_git: Score files using git recency signal (default: True).
35+
use_import_graph: Pull in files imported by high-scoring Python modules (default: True).
36+
import_graph_depth: How many import hops to follow (default: 1).
3437
"""
3538

3639
def __init__(
@@ -42,6 +45,8 @@ def __init__(
4245
include_patterns: list[str] | None = None,
4346
exclude_patterns: list[str] | None = None,
4447
use_git: bool = True,
48+
use_import_graph: bool = True,
49+
import_graph_depth: int = 1,
4550
) -> None:
4651
self.root = Path(root).resolve()
4752
self.model = model
@@ -50,6 +55,8 @@ def __init__(
5055
self.include_patterns = include_patterns
5156
self.exclude_patterns = exclude_patterns
5257
self.use_git = use_git
58+
self.use_import_graph = use_import_graph
59+
self.import_graph_depth = import_graph_depth
5360

5461
def build(
5562
self,
@@ -108,6 +115,16 @@ def build(
108115
for path, content, score in ranked
109116
]
110117

118+
if self.use_import_graph and self.import_graph_depth > 0:
119+
paths_in_raw = [p for p, _ in raw]
120+
graph = build_import_graph(self.root, paths_in_raw)
121+
context_files = expand_with_imports(
122+
context_files,
123+
graph,
124+
self.root,
125+
max_depth=self.import_graph_depth,
126+
)
127+
111128
# 4. Optimize for token budget
112129
query_tokens = count_tokens(query, self.model) if query else 0
113130
system_tokens = count_tokens(system_prompt, self.model) if system_prompt else 0

0 commit comments

Comments
 (0)