From 980c61da30afc7f8db54a6631ab3727b7895aba1 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 00:11:09 -0600 Subject: [PATCH 1/2] perf(kernel): read a plain Python import line instead of parsing the file again for each package-import base A class whose base is a package import (`import unittest` then `class T(unittest.TestCase)`) made the resolver parse the whole file to learn whether the import statement names the package, and its tree cache holds 32 files. Walking the subclasses of a class that has no own method of that name (`B.__init__`) did that for thousands of files. A plain one-line `import a.b, c` statement is now read from its line; other shapes still use the tree. --- codegraph-kernel/src/resolve/imports.rs | 47 ++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/codegraph-kernel/src/resolve/imports.rs b/codegraph-kernel/src/resolve/imports.rs index 7104e8656d..1e78f26c95 100644 --- a/codegraph-kernel/src/resolve/imports.rs +++ b/codegraph-kernel/src/resolve/imports.rs @@ -1317,9 +1317,15 @@ impl KernelResolver { return false; } let Some(file) = self.read_file(&r.file_path) else { return false }; - let Some(tree) = self.parsed_tree(&file, r) else { return false }; let row = (binding.line - 1).max(0) as usize; let Some(line) = file.get(row) else { return false }; + // A plain one-line `import a.b, c` names its modules on the line; + // reading the tree parses the whole file again for each class that + // imports a package, and the tree cache holds only a few files. + if let Some(named) = plain_import_names(line, source) { + return named; + } + let Some(tree) = self.parsed_tree(&file, r) else { return false }; let point = tree_sitter::Point::new(row, line.len() - line.trim_start().len()); let mut node = tree.root_node().named_descendant_for_point_range(point, point); while let Some(current) = node { @@ -1471,3 +1477,42 @@ impl KernelResolver { fn is_declaration_file(path: &str) -> bool { [".d.ts", ".d.mts", ".d.cts"].iter().any(|ext| path.ends_with(ext)) } + +/// Whether the `import` statement on `line` names the module `source` as a +/// plain dotted name (`import a.b, c`, not `import a.b as c`). `None` when +/// the line is not a plain one-line `import` statement, so the caller reads +/// the tree. +fn plain_import_names(line: &str, source: &str) -> Option { + let clause = line.trim_start().strip_prefix("import")?; + if !clause.starts_with([' ', '\t']) { + return None; + } + let clause = clause.split(['#', ';']).next().unwrap_or(clause); + if clause.contains(['\\', '(', '"', '\'']) { + return None; + } + Some(clause.split(',').any(|name| name.trim() == source)) +} + +#[cfg(test)] +mod plain_import_tests { + use super::plain_import_names; + + #[test] + fn names_a_dotted_module_on_a_plain_import_line() { + assert_eq!(plain_import_names("import os.path", "os.path"), Some(true)); + assert_eq!(plain_import_names(" import os, os.path # paths", "os.path"), Some(true)); + assert_eq!(plain_import_names("import os.path; x = 1", "os.path"), Some(true)); + assert_eq!(plain_import_names("import os", "os.path"), Some(false)); + assert_eq!(plain_import_names("import os.path as osp", "os.path"), Some(false)); + } + + #[test] + fn leaves_other_shapes_to_the_tree() { + assert_eq!(plain_import_names("from os import path", "os.path"), None); + assert_eq!(plain_import_names("importlib = 1", "importlib"), None); + assert_eq!(plain_import_names("x = 1; import os", "os"), None); + assert_eq!(plain_import_names("import os, \\", "os"), None); + assert_eq!(plain_import_names("import (os)", "os"), None); + } +} From 6c6682b4b3656474f896b33477bdbe3f906297ff Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 00:57:06 -0600 Subject: [PATCH 2/2] docs(changelog): note the faster Python one-file sync --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6aeb4fcf..7fb0db2095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- **Syncing a changed file in a large Python project is faster.** When a Python class has no method of the name being linked (a test class that passes `Handler.__init__` as a value, say), the resolver checks every class with that method for one that derives from it. For each class whose base is a package import (`import unittest`, then `unittest.TestCase`) it parsed the whole file again, and its cache holds only a few files, so two such references took about a second each. A plain one-line `import a.b` is now read from its line; other forms still use the parse. In CPython a one-file sync takes a median 5.5 s instead of 7.7 s on Node and 5.2 s instead of 8.1 s on Bun (4 alternating runs per build, ranges not overlapping; the phase that retries unresolved references drops from 4.6 s to 2.3 s). A full index is within the run-to-run variation, and the graph is the same on CPython and Django (no edge differs). - **Questions to the MCP server are faster in a project with no `codegraph.json`.** While ranking results, CodeGraph checked for the project's `codegraph.json` once per result path, and with no such file each check raised and caught an error. It now asks the file system without raising, so `codegraph_explore` and the other query tools spend less CPU on most projects. Editing, adding or deleting `codegraph.json` is still picked up without a restart (#491). - **Indexing a project with a bundled minified JavaScript file is faster.** For each reference in a one-line file such as `jquery.min.js`, the resolver counted characters from the start of the line to find the reference's column, many times per reference. It now skips straight to the column when the text before it is ASCII. Alamofire, whose docs folder carries jQuery, resolves its references in a median 0.93 s instead of 1.25 s (profiling runs on a busy host, 5,614 nodes and 13,673 edges either way). Separately, the resolver now looks nodes up by query only when there are fewer than 15,000 pending references and fewer than 0.3 per node; at either limit it loads the node table. A full index of a small project such as gin (about 4.4 references per node) therefore loads the table, saving about 60 ms of its one-second index, while a one-file sync still queries. In the 2026-10-12 benchmark on Node, a full index of Alamofire takes 1.81 s against upstream's 1.73 s and gin 1.15 s against 1.13 s ([results](docs/benchmarks/fork-vs-upstream-node-bun-2026-10-12.md)). - **Indexing a project with long minified lines that contain a non-ASCII character is faster.** The resolver turns a reference's column into a byte offset many times per reference, and when any character before the column was not ASCII it walked every character on the line each time. It now counts a 64-byte block at a time and walks characters only inside the block that holds the column. Indexing JetBrains Exposed, whose docs folder carries minified scripts, takes a median 25.7 s instead of 37.0 s (5 alternating runs per build, baseline against baseline within 0.3%; 28,689 nodes and 82,842 edges either way). nocodb, with no long lines, is unchanged within the run-to-run band (6.53 s against 6.74 s).