Skip to content

Commit 6c85850

Browse files
committed
fix(resolver): resolve Python builtins through the formal tier (DEBT-005)
Root cause was investigated during earlier work: tree-sitter-stack-graphs- python 0.3.0's bundled src/builtins.py ships empty, so FormalResolver's merged builtins graph had nothing in it — any reference to len/print/range/ etc. fell through to the resolved/textual tier instead of formal, despite stack-graphs.tsg wiring up a "<builtins>" push-symbol fallback edge for exactly this case. Fix required no TSG grammar patch. build_python_builtins_graph() builds a StackGraph from a synthetic PYTHON_BUILTINS_STUB (~70 common builtins as plain def/class stubs) through the same compiled `sgl` rules the upstream crate already provides, using FILE_PATH="<builtins>.py". That exact path satisfies stack-graphs.tsg's own per-file module-path rule (the regex branch that turns a file's relative path into a pop_symbol chain anchored at ROOT_NODE), producing a pop_symbol="<builtins>" node — the missing counterpart to every file's push_symbol="<builtins>" fallback edge. Verified by two new tests (len()/print() resolve; a genuinely undefined name still doesn't) and a real `ci index` smoke test showing call_sites.confidence = 'formal' for len/print/enumerate/isinstance/sorted calls. Known scope limit, documented in pattern-debt-registry.yaml and the README: this is correct at the call_sites storage layer, but rebuild_graph filters call_sites -> call_edges by name-match against indexed project symbols, independent of confidence — builtins never match (they aren't project symbols) so they still don't produce call_edges rows or show up in callers/path/caller_count_by_confidence. Making that visible would need builtins to become pseudo-symbols, which risks skewing hub_count/coreness project-wide (len/print called everywhere) — deliberately left as a separate, explicitly-tracked follow-up rather than folded in here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WTXEBnJdVd1o2egp1bTBaX
1 parent 0e36c88 commit 6c85850

3 files changed

Lines changed: 192 additions & 34 deletions

File tree

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,11 @@ tính graph metrics (coreness/hubs), và phục vụ qua SQLite FTS5 + semantic
2020
2. **Call graph phân cấp** — mỗi edge mang một mức tin cậy:
2121
- `resolved` — khớp file symbol / import / alias (tier-1, conservative resolver).
2222
- `inferred` — method call phân giải theo kiểu của receiver (tier-2: `self`/`this` → class bao quanh; biến typed → `type_map`).
23-
- `formal` — phân giải phạm vi tĩnh qua `stack-graphs` (tier-3, hiện hỗ trợ Python). Được bảo vệ bởi **hai deadline độc lập**: một cho bước build stack-graph (TSG) và một cho bước path-stitching, cộng thêm cap `MAX_WORK_PER_PHASE = 4096` để chống DoS.
24-
> **Lưu ý `formal` tier**: `formal` **không resolve được builtin Python** (`len`, `print`, `range`...) —
25-
> `src/builtins.py` bundled trong `tree-sitter-stack-graphs-python` 0.3.0 rỗng, và rule TSG upstream
26-
> push symbol `"<builtins>"` nhưng không có rule nào pop lại — dead-end ở tầng grammar, không phải bug
27-
> phía `ci`. Repo upstream (`github/stack-graphs`) đã bị archive từ 9/2025, không còn nhận fix. Các
28-
> reference gọi builtin sẽ fall back về `resolved`/`textual` tier như bình thường.
23+
- `formal` — phân giải phạm vi tĩnh qua `stack-graphs` (tier-3, hiện hỗ trợ Python). Được bảo vệ bởi **hai deadline độc lập**: một cho bước build stack-graph (TSG) và một cho bước path-stitching, cộng thêm cap `MAX_WORK_PER_PHASE = 4096` để chống DoS. Python builtins (`len`, `print`, `range`...) resolve qua tier này nhờ `build_python_builtins_graph` tự build (bundled `src/builtins.py` của `tree-sitter-stack-graphs-python` 0.3.0 rỗng, không dùng được trực tiếp).
24+
> **Lưu ý**: builtin call hiện được gắn đúng `edge_confidence: formal` ở tầng lưu trữ nội bộ
25+
> (`call_sites`), nhưng **chưa hiển thị qua `callers`/`path`/`caller_count_by_confidence`**
26+
> các tool đó chỉ đọc `call_edges`, vốn chỉ chứa cạnh giữa 2 symbol đã index trong project;
27+
> builtin không phải project symbol nên không tạo `call_edges` dù ở tier nào.
2928
- `textual` — chỉ khớp tên (fallback).
3029
3. **Import graph**`import_edges` (file→module/file) cho tool `dependencies`.
3130
4. **Graph metrics**`coreness` (k-core, O(V+E)) và `is_hub` để AI biết đâu là lõi hệ thống.

crates/ci-core/src/resolver/formal.rs

Lines changed: 170 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,111 @@ const RESOLVE_TIMEOUT: Duration = Duration::from_secs(3);
5555
/// before the deadline check below ever gets a chance to fire.
5656
const MAX_WORK_PER_PHASE: usize = 4096;
5757

58+
/// Synthetic source for a virtual "<builtins>.py" file, standing in for
59+
/// `tree-sitter-stack-graphs-python` 0.3.0's own bundled `src/builtins.py`
60+
/// (which ships empty — see DEBT-005 / the regression test below for the
61+
/// original investigation). Bodies are `pass` — only the *names* need to
62+
/// exist as top-level definitions for references to resolve to; nothing
63+
/// calls into these bodies. Not exhaustive, but covers the builtins that
64+
/// show up in real code by a wide margin.
65+
const PYTHON_BUILTINS_STUB: &str = r#"
66+
def print(*args, **kwargs): pass
67+
def len(obj): pass
68+
def range(*args): pass
69+
def isinstance(obj, cls): pass
70+
def issubclass(cls, classinfo): pass
71+
def super(*args): pass
72+
def open(*args, **kwargs): pass
73+
def enumerate(iterable, start=0): pass
74+
def zip(*iterables): pass
75+
def map(func, *iterables): pass
76+
def filter(func, iterable): pass
77+
def sorted(iterable, *, key=None, reverse=False): pass
78+
def reversed(seq): pass
79+
def min(*args, **kwargs): pass
80+
def max(*args, **kwargs): pass
81+
def sum(iterable, start=0): pass
82+
def abs(x): pass
83+
def round(number, ndigits=None): pass
84+
def all(iterable): pass
85+
def any(iterable): pass
86+
def iter(obj, *args): pass
87+
def next(iterator, *args): pass
88+
def hasattr(obj, name): pass
89+
def getattr(obj, name, *args): pass
90+
def setattr(obj, name, value): pass
91+
def delattr(obj, name): pass
92+
def callable(obj): pass
93+
def repr(obj): pass
94+
def format(value, spec=""): pass
95+
def id(obj): pass
96+
def hash(obj): pass
97+
def vars(*args): pass
98+
def dir(*args): pass
99+
def input(*args): pass
100+
def staticmethod(func): pass
101+
def classmethod(func): pass
102+
def property(*args): pass
103+
104+
class object: pass
105+
class type: pass
106+
class str: pass
107+
class int: pass
108+
class float: pass
109+
class bool: pass
110+
class complex: pass
111+
class list: pass
112+
class dict: pass
113+
class set: pass
114+
class frozenset: pass
115+
class tuple: pass
116+
class bytes: pass
117+
class bytearray: pass
118+
119+
class BaseException: pass
120+
class Exception: pass
121+
class ValueError: pass
122+
class TypeError: pass
123+
class KeyError: pass
124+
class IndexError: pass
125+
class AttributeError: pass
126+
class StopIteration: pass
127+
class RuntimeError: pass
128+
class NotImplementedError: pass
129+
class ZeroDivisionError: pass
130+
class NameError: pass
131+
class ImportError: pass
132+
class OSError: pass
133+
class FileNotFoundError: pass
134+
class KeyboardInterrupt: pass
135+
"#;
136+
137+
/// Builds a `StackGraph` holding definitions for `PYTHON_BUILTINS_STUB`,
138+
/// reusing the *same* compiled TSG rules (`sgl`) the upstream crate uses for
139+
/// ordinary files — no grammar patch needed. The FILE_PATH `"<builtins>.py"`
140+
/// is the load-bearing part: the grammar's per-file module-path rule (the
141+
/// branch that turns a file's relative path into a `pop_symbol` chain
142+
/// anchored at ROOT_NODE) turns this exact path into a single
143+
/// `pop_symbol = "<builtins>"` node hanging directly off ROOT_NODE — which is
144+
/// precisely the counterpart every file's `push_symbol = "<builtins>"`
145+
/// fallback edge (the one stack-graphs.tsg wires up for any reference that
146+
/// falls through local scope) was missing.
147+
fn build_python_builtins_graph(sgl: &StackGraphLanguage) -> anyhow::Result<StackGraph> {
148+
let mut graph = StackGraph::new();
149+
let file = graph.get_or_create_file("<builtins>.py");
150+
151+
let mut globals = Variables::new();
152+
globals
153+
.add("FILE_PATH".into(), "<builtins>.py".into())
154+
.map_err(|_| anyhow::anyhow!("Failed to set FILE_PATH global for builtins"))?;
155+
156+
let deadline = TsgCancelAfterDuration::new(RESOLVE_TIMEOUT);
157+
sgl.build_stack_graph_into(&mut graph, file, PYTHON_BUILTINS_STUB, &globals, &deadline)
158+
.map_err(|e| anyhow::anyhow!("Failed to build Python builtins stack graph: {e:?}"))?;
159+
160+
Ok(graph)
161+
}
162+
58163
/// Same as `ForwardPartialPathStitcher::find_minimal_partial_path_set_in_file`
59164
/// (stack-graphs 0.14), but with `max_work_per_phase` bounded — see
60165
/// `MAX_WORK_PER_PHASE` for why.
@@ -150,11 +255,16 @@ impl FormalResolver {
150255
pub fn load_python(&mut self) -> anyhow::Result<()> {
151256
let lc = tree_sitter_stack_graphs_python::try_language_configuration(cancellation_flag())
152257
.map_err(|e| anyhow::anyhow!("Failed to load Python stack-graphs config: {e}"))?;
258+
// Upstream's own `lc.builtins` is built from its bundled (empty)
259+
// src/builtins.py — replace it with our own, built through the same
260+
// `sgl` rules. See `build_python_builtins_graph` for why this alone
261+
// is enough to make builtins resolve, with no grammar patch.
262+
let builtins = build_python_builtins_graph(&lc.sgl)?;
153263
self.configs.insert(
154264
"python".to_string(),
155265
FormalLanguageConfig {
156266
sgl: lc.sgl,
157-
builtins: lc.builtins,
267+
builtins,
158268
no_similar_paths_in_file: lc.no_similar_paths_in_file,
159269
},
160270
);
@@ -354,18 +464,10 @@ def bar():
354464
/// unnoticed since it was never touched.
355465
///
356466
/// NOTE on scope: this only verifies the merge happens correctly (file
357-
/// count grows, no `add_from_graph` error). It deliberately does NOT
358-
/// assert that a Python builtin like `len()` resolves end-to-end —
359-
/// investigating that surfaced two independent upstream gaps in the
360-
/// pinned `tree-sitter-stack-graphs-python` 0.3.0: (1) its bundled
361-
/// `src/builtins.py` is empty (`include_str!` yields 0 bytes), and (2)
362-
/// even with a synthetic non-empty builtins file built through the same
363-
/// real `sgl` rules, `stack-graphs.tsg`'s `global -> ROOT_NODE` edge
364-
/// pushes the symbol `"<builtins>"` but no node anywhere in that grammar
365-
/// pops `"<builtins>"` — the binding is a dead end at the grammar level,
366-
/// not something this merge can route around. Fixing that needs a newer/
367-
/// different `tree-sitter-stack-graphs-python` version or a project-
368-
/// authored builtins.py + matching tsg rule, both out of scope here.
467+
/// count grows, no `add_from_graph` error) — DEBT-005 covers the actual
468+
/// builtin *resolution* (see `test_resolve_file_resolves_python_builtins`
469+
/// below), since `config.builtins` here is now `ci`'s own
470+
/// `build_python_builtins_graph` output, not upstream's empty one.
369471
#[test]
370472
fn test_resolve_file_merges_builtins_without_error() {
371473
let mut resolver = FormalResolver::new();
@@ -378,7 +480,7 @@ def bar():
378480
// would pass vacuously.
379481
assert!(
380482
builtins_file_count > 0,
381-
"builtins graph should contain at least the <builtins> file, even though its source is empty"
483+
"builtins graph should contain at least the <builtins> file"
382484
);
383485

384486
let mut graph = StackGraph::new();
@@ -401,6 +503,60 @@ def bar():
401503
);
402504
}
403505

506+
/// DEBT-005: `len()` and `print()` must resolve to the synthetic
507+
/// `build_python_builtins_graph` definitions through the `formal` tier —
508+
/// the actual fix, not just "the merge doesn't crash" (see
509+
/// `test_resolve_file_merges_builtins_without_error` above).
510+
#[test]
511+
fn test_resolve_file_resolves_python_builtins() {
512+
let mut resolver = FormalResolver::new();
513+
resolver.load_python().unwrap();
514+
515+
let edges = resolver
516+
.resolve_file(
517+
"python",
518+
"test.py",
519+
"def use_builtins():\n print(len([1, 2, 3]))\n",
520+
)
521+
.unwrap();
522+
523+
assert!(
524+
edges
525+
.iter()
526+
.any(|e| e.reference_symbol == "len" && e.definition_symbol == "len"),
527+
"len() must resolve through the formal tier. Edges: {edges:?}"
528+
);
529+
assert!(
530+
edges
531+
.iter()
532+
.any(|e| e.reference_symbol == "print" && e.definition_symbol == "print"),
533+
"print() must resolve through the formal tier. Edges: {edges:?}"
534+
);
535+
}
536+
537+
/// A genuinely undefined name must still fail to resolve — the builtins
538+
/// fix must not make FormalResolver resolve *everything*.
539+
#[test]
540+
fn test_resolve_file_does_not_resolve_undefined_name() {
541+
let mut resolver = FormalResolver::new();
542+
resolver.load_python().unwrap();
543+
544+
let edges = resolver
545+
.resolve_file(
546+
"python",
547+
"test.py",
548+
"def use_undefined():\n return totally_undefined_xyz()\n",
549+
)
550+
.unwrap();
551+
552+
assert!(
553+
!edges
554+
.iter()
555+
.any(|e| e.reference_symbol == "totally_undefined_xyz"),
556+
"a genuinely undefined name must not resolve. Edges: {edges:?}"
557+
);
558+
}
559+
404560
#[test]
405561
fn test_resolve_python_no_refs() {
406562
let mut resolver = FormalResolver::new();

docs/pattern-debt-registry.yaml

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,24 +54,27 @@ items:
5454
owner_hint: testing
5555

5656
DEBT-005-formal-python-builtins-unresolved:
57-
status: open
58-
urgency: low
57+
status: resolved
58+
urgency: done
59+
resolved_at: "2026-07-01"
5960
description: >
6061
FormalResolver (formal.rs) không resolve được Python builtins (len, print,
61-
range...). Root cause đã điều tra trong test_resolve_file_merges_builtins_
62-
without_error (formal.rs): src/builtins.py bundled trong
62+
range...). Root cause: src/builtins.py bundled trong
6363
tree-sitter-stack-graphs-python 0.3.0 rỗng, và stack-graphs.tsg push symbol
64-
"<builtins>" nhưng không rule nào pop lại — dead-end ở tầng grammar upstream,
65-
không sửa được từ phía merge builtins graph. Verified 2026-07-01: 0.3.0 vẫn
66-
là bản mới nhất (12/2024), repo github/stack-graphs đã bị archive 9/2025 nên
67-
sẽ không có upstream fix. Không ảnh hưởng correctness — reference gọi
68-
builtin fall back về resolved/textual tier bình thường, chỉ mất confidence
69-
tier cao nhất.
70-
current_control: "README caveat dưới bullet formal tier; fallback resolver vẫn hoạt động"
64+
"<builtins>" mà không có source builtins nào tận dụng đúng cơ chế per-file
65+
module-path để pop lại nó. Fix: build_python_builtins_graph() tự build
66+
StackGraph cho một PYTHON_BUILTINS_STUB (~70 builtin phổ biến) bằng đúng
67+
`sgl` upstream, với FILE_PATH="<builtins>.py" — path này khớp regex
68+
"([^/]+)\.py$" của chính stack-graphs.tsg, tự nhiên tạo ra node
69+
pop_symbol="<builtins>" gắn thẳng vào ROOT_NODE. KHÔNG cần vá/fork TSG rule
70+
nào — chỉ cần gọi đúng API public (build_stack_graph_into) với nội dung và
71+
FILE_PATH phù hợp. Verified bằng test thật (test_resolve_file_resolves_
72+
python_builtins) + smoke test CLI thật (index file dùng len/print/enumerate/
73+
isinstance/sorted → call_sites.confidence='formal' cho cả 5).
74+
current_control: "build_python_builtins_graph() trong formal.rs; 2 regression test (resolves builtins + vẫn từ chối tên thật sự undefined)"
7175
remaining:
72-
- "Xác nhận tree_sitter_stack_graphs::StackGraphLanguage::from_str + tsg_source() đủ để tự vá TSG rule (pop \"<builtins>\") mà không cần fork toàn crate"
73-
- "Tự viết builtins.py (định nghĩa len/print/range/...) + override rule TSG bù chỗ thiếu pop \"<builtins>\""
74-
- "Regression test: len()/print() resolve tới definition_symbol qua formal tier, không chỉ textual"
76+
- "GIỚI HẠN ĐÃ BIẾT: call_sites.confidence='formal' cho builtin KHÔNG tạo ra call_edges row — rebuild_graph() (pipeline.rs) lọc theo tên khớp symbols đã index trong project, độc lập với confidence, nên builtin (không phải project symbol) không bao giờ có call_edges dù tier nào. Nghĩa là callers/path/CallerCountByConfidence chưa thấy được thay đổi này — chỉ đúng ở tầng lưu trữ call_sites, sẵn sàng cho consumer tương lai."
77+
- "Nếu muốn builtin hiển thị qua tool: cần thiết kế riêng (builtin thành pseudo-symbol?) — rủi ro thật: len/print sẽ có caller_count khổng lồ khắp mọi codebase Python, có thể làm lệch hub_count/coreness. Cố ý chưa làm, cần bàn kỹ trước khi động vào."
7578
owner_hint: ci-core/resolver
7679

7780
DEBT-006-ty-subprocess-premise-invalid:

0 commit comments

Comments
 (0)