Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ Protocol) stdio server. No AI / vector / LLM anywhere in the binary — output i
(`.tscn` `uid=`) — byte-for-byte) and
`reference/golden/ruby/` (corpus `crates/codegraph-bench/fixtures/ruby/`; guards #1110
Ruby `receiver.method` extraction — instance/class-method Calls, `Const.new` Instantiates,
bare `include` Implements — byte-for-byte) and `reference/golden/cpp/`
bare `include` Implements — byte-for-byte), `reference/golden/python/` (corpus
`crates/codegraph-bench/fixtures/python/`; guards six bare class-as-value
`References` edges — return, assignment RHS, registry pair, call argument,
list literal, and one cross-file unique match — plus tuple-return and bare-method
negatives, byte-for-byte), and `reference/golden/cpp/`
(corpus `crates/codegraph-bench/fixtures/cpp/`; guards #1043 C++ class/struct
inheritance incl. templated-base stripping byte-for-byte, and retroactively
the earlier C++ extraction work).
Regen recipe: `docs/equivalence.md` "Godot fixture" / "Ruby fixture" / "C++ fixture" sections.
The Python cross-file edge is resolved by Gate 3a's unique-name fallback;
import Gate 3b is deliberately unreachable for real Python nodes because the
extractor does not mark them exported. Regen recipe: `docs/equivalence.md`
"Godot fixture" / "Ruby fixture" / "Python fixture" / "C++ fixture" sections.
- **node-id formula**: `{kind}:{sha256("{filePath}:{kind}:{name}:{line}").hex[:32]}`; file nodes are the
literal `file:{relpath}`; lines are 1-based; paths relative with `/`.
- **No AI / vector / LLM crates** — enforced by `scripts/guardrail.sh` (CI gate):
Expand Down
5 changes: 5 additions & 0 deletions crates/codegraph-bench/fixtures/python/consumers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from imported_types import ImportedClass


def choose_imported():
return ImportedClass
2 changes: 2 additions & 0 deletions crates/codegraph-bench/fixtures/python/imported_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ImportedClass:
pass
59 changes: 59 additions & 0 deletions crates/codegraph-bench/fixtures/python/same_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
class ReturnClass:
pass


class AliasClass:
pass


class RegistryClass:
pass


class ArgumentClass:
pass


class ListClass:
pass


class TupleA:
pass


class TupleB:
pass


class HandlerOwner:
def handler(self):
return None


def choose_return():
return ReturnClass


def choose_alias():
alias = AliasClass


def choose_registry():
registry = {"registry": RegistryClass}


def choose_argument():
register(ArgumentClass)


def choose_list():
values = [ListClass]


def choose_tuple():
return TupleA, TupleB


def wire(handler):
register(handler)
24 changes: 24 additions & 0 deletions crates/codegraph-bench/tests/equivalence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ fn cpp_db_is_self_equivalent_to_cpp_golden() {
assert_equivalent(&cpp_db(), &cpp_golden_dir()).unwrap();
}

#[test]
fn generated_golden_matches_committed_python_fixture() {
let tempdir = TestDir::new("generated-golden-python");
write_golden(&python_db(), tempdir.path()).unwrap();

let expected = load_golden(&python_golden_dir()).unwrap();
let actual = load_golden(tempdir.path()).unwrap();

diff_canonical(&expected, &actual, None).unwrap();
}

#[test]
fn python_db_is_self_equivalent_to_python_golden() {
assert_equivalent(&python_db(), &python_golden_dir()).unwrap();
}

#[test]
fn generated_golden_matches_committed_metal_fixture() {
// Guards Metal (#1121): `.metal`→cpp mapping + the `[[attribute]]` blank that
Expand Down Expand Up @@ -364,6 +380,14 @@ fn cpp_golden_dir() -> PathBuf {
workspace_root().join("reference/golden/cpp")
}

fn python_db() -> PathBuf {
workspace_root().join("reference/golden/python/colby.db")
}

fn python_golden_dir() -> PathBuf {
workspace_root().join("reference/golden/python")
}

fn metal_db() -> PathBuf {
workspace_root().join("reference/golden/metal/colby.db")
}
Expand Down
70 changes: 70 additions & 0 deletions crates/codegraph-extract/src/function_ref.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const PYTHON_SPEC: FnRefSpec = FnRefSpec {
("keyword_argument", CaptureMode::Value, Some("value")),
("pair", CaptureMode::Value, Some("value")),
("list", CaptureMode::List, None),
("return_statement", CaptureMode::List, None),
],
layers: &[],
unwrap: &[],
Expand Down Expand Up @@ -1221,6 +1222,75 @@ class W:
assert!(has_fn_ref(&refs, "handler"), "names={:?}", names(&refs));
}

#[test]
fn python_captures_single_return_value_but_not_tuple_members() {
let src = r#"
def handler():
return 1

def first():
return 2

def second():
return 3

def choose_single():
return handler

def choose_tuple():
return first, second
"#;
let refs = fn_refs("src/returns.py", src, Language::Python);
assert!(has_fn_ref(&refs, "handler"), "names={:?}", names(&refs));
assert!(
!has_fn_ref(&refs, "first"),
"tuple members must not be captured: names={:?}",
names(&refs)
);
assert!(
!has_fn_ref(&refs, "second"),
"tuple members must not be captured: names={:?}",
names(&refs)
);
}

#[test]
fn python_captures_same_file_classes_in_value_containers() {
let src = r#"
class AliasClass:
pass

class RegistryClass:
pass

class ArgumentClass:
pass

class ListClass:
pass

def choose_alias():
alias = AliasClass

def choose_registry():
registry = {"registry": RegistryClass}

def choose_argument():
register(ArgumentClass)

def choose_list():
values = [ListClass]
"#;
let refs = fn_refs("src/classes.py", src, Language::Python);
for name in ["AliasClass", "RegistryClass", "ArgumentClass", "ListClass"] {
assert!(
has_fn_ref(&refs, name),
"missing {name}: names={:?}",
names(&refs)
);
}
}

// --- Go ----------------------------------------------------------------

#[test]
Expand Down
19 changes: 12 additions & 7 deletions crates/codegraph-extract/src/walker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3738,10 +3738,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {
}

/// Gate captured function-value candidates and push survivors as
/// `function_ref` references. A bare-name candidate survives only if its
/// name is a function/method DEFINED in this file or an imported name;
/// `this.<member>` candidates always flush (class-scoped at resolution).
/// Ports `flushFnRefCandidates` (tree-sitter.ts:429-521), TS/JS gate.
/// `function_ref` references. `this.<member>` and `::` candidates always
/// flush. C-family file-scope candidates in an ungated mode bypass the name
/// gate, as do `skip_gate` candidates (PHP HOF strings). Every other bare
/// name must be a function/method defined in this file, a Python class
/// defined in this file, or an imported name.
/// Ports `flushFnRefCandidates` (tree-sitter.ts:429-521) from the upstream
/// TS/JS gate, adapted here across languages.
fn flush_fn_ref_candidates(&mut self) {
if self.fn_ref_candidates.is_empty() {
return;
Expand All @@ -3754,7 +3757,9 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {

let mut defined_here: std::collections::HashSet<&str> = std::collections::HashSet::new();
for n in &self.nodes {
if matches!(n.kind, NodeKind::Function | NodeKind::Method) {
if matches!(n.kind, NodeKind::Function | NodeKind::Method)
|| (self.spec.language() == Language::Python && n.kind == NodeKind::Class)
{
defined_here.insert(n.name.as_str());
}
}
Expand Down Expand Up @@ -3800,8 +3805,8 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {
}
// Gate by candidate shape: `this.`/`::` always flush; C-family
// file-scope initializers (ungated_modes) skip; PHP HOF strings
// (skip_gate) skip; everything else must be a same-file fn/method
// or an import (tree-sitter.ts:493-512).
// (skip_gate) skip; everything else must be a same-file fn/method,
// a same-file Python class, or an import (tree-sitter.ts:493-512).
if !cand.name.starts_with("this.") && !cand.name.contains("::") {
let skip_gate = (at_file_scope
&& crate::function_ref::mode_is_ungated(spec, cand.mode))
Expand Down
62 changes: 55 additions & 7 deletions crates/codegraph-resolve/src/name_matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2020,11 +2020,15 @@ pub fn match_reference(
match_fuzzy(reference, context)
}

pub(crate) fn is_python_class_function_ref_target(language: Language, kind: NodeKind) -> bool {
language == Language::Python && kind == NodeKind::Class
}

/// Resolve a `function_ref` (callback-as-value) reference: exact name,
/// function/method targets only, same language family, same-file first,
/// cross-file only when unique. No fuzzy fallback. `this.<member>` refs are
/// resolved elsewhere (resolve_this_member_fn_ref). Ports `matchFunctionRef`
/// (name-matcher.ts:179-310).
/// function/method targets plus Python class targets (Python bare methods remain
/// excluded), same language family, same-file first, cross-file only when unique.
/// No fuzzy fallback. `this.<member>` refs are resolved elsewhere
/// (resolve_this_member_fn_ref). Ports `matchFunctionRef` (name-matcher.ts:179-310).
pub fn match_function_ref(
reference: &RefView,
context: &dyn ResolutionContext,
Expand All @@ -2033,8 +2037,9 @@ pub fn match_function_ref(
return None;
}

// A bare identifier can never be a method value in JS/TS/C++/Python/PHP
// (methods need a receiver), so those match FUNCTIONS only.
// A bare identifier cannot be a method value in JS/TS/C++/Python/PHP
// (methods need a receiver). Those match FUNCTIONS; Python additionally
// accepts CLASS targets through the shared predicate, but still not METHODS.
let bare_fn_only = matches!(
reference.language,
Language::TypeScript
Expand Down Expand Up @@ -2093,7 +2098,9 @@ pub fn match_function_ref(
.get_nodes_by_name(&reference.reference_name)
.into_iter()
.filter(|n| {
(n.kind == NodeKind::Function || (!bare_fn_only && n.kind == NodeKind::Method))
(n.kind == NodeKind::Function
|| (!bare_fn_only && n.kind == NodeKind::Method)
|| is_python_class_function_ref_target(reference.language, n.kind))
&& same_language_family(n.language, reference.language)
&& n.id != reference.from_node_id
})
Expand Down Expand Up @@ -3800,6 +3807,47 @@ mod tests {
assert!(match_function_ref(&r, &ctx).is_none());
}

#[test]
fn function_ref_bare_class_target_matrix() {
let cases = [
(Language::TypeScript, NodeKind::Class, false),
(Language::Tsx, NodeKind::Class, false),
(Language::JavaScript, NodeKind::Class, false),
(Language::Jsx, NodeKind::Class, false),
(Language::Cpp, NodeKind::Class, false),
(Language::Php, NodeKind::Class, false),
(Language::Python, NodeKind::Method, false),
(Language::Python, NodeKind::Class, true),
];

for (language, kind, should_resolve) in cases {
let target = mk(
"target:id",
kind,
"Target",
"Target",
"src/target",
language,
);
let ctx = Ctx::default().name("Target", vec![target]);
let r = refv("Target", EdgeKind::References, "src/consumer", language, 1);
let resolved = match_function_ref(&r, &ctx);

if should_resolve {
let resolved = resolved.unwrap_or_else(|| {
panic!("{language:?} {kind:?} must be a function-ref target")
});
assert_eq!(resolved.target_node_id, "target:id");
assert_eq!(resolved.resolved_by, ResolvedBy::FunctionRef);
} else {
assert!(
resolved.is_none(),
"{language:?} {kind:?} must not be a function-ref target"
);
}
}
}

#[test]
fn function_ref_qualified_member_pointer_unique() {
// C++ `&Widget::on_click` — resolve the member on that scope.
Expand Down
Loading
Loading