Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7bd29d6
fix(extraction): bind a unique trait bound as the callee
cursoragent Sep 19, 2026
3a86297
fix(global-db): replay an applied cursor coverage as a duplicate
ScriptedAlchemy Sep 19, 2026
d1da671
fix(session-temporal): page reads that were refusing stored results
ScriptedAlchemy Sep 19, 2026
9e2f7be
Merge pull request #1842 from ScriptedAlchemy/fix/serve-stored-sessio…
ScriptedAlchemy Sep 19, 2026
dfbcb15
Merge remote-tracking branch 'origin/master' into fix/master-ci-green-3
ScriptedAlchemy Sep 19, 2026
fc4b848
style(global-db): format the observation collision tests
ScriptedAlchemy Sep 19, 2026
f550e77
fix(hermes): converge past refused rows instead of eternal skip
ScriptedAlchemy Sep 19, 2026
278d18f
fix(ingest): treat a peer-covered cursor CAS loss as a no-op
ScriptedAlchemy Sep 19, 2026
19ba2b6
test(codex): count replay index visits per thread
ScriptedAlchemy Sep 19, 2026
9f8092d
test(ingest): keep the shared meta cache off its degraded mode
ScriptedAlchemy Sep 19, 2026
822b181
fix(session-temporal): drop the no-op drop in the doctor test
ScriptedAlchemy Sep 19, 2026
259256d
fix(session-temporal): count parent copies without loading the graph
ScriptedAlchemy Sep 19, 2026
e89091f
Merge pull request #1844 from ScriptedAlchemy/fix/refresh-copy-count
ScriptedAlchemy Sep 19, 2026
4e1a203
Merge remote-tracking branch 'origin/master' into fix/master-ci-green-3
ScriptedAlchemy Sep 19, 2026
8f33ad6
test(sessions): re-reasoned cursor replay is a duplicate
ScriptedAlchemy Sep 19, 2026
c39a2ed
fix(session-temporal): split observation prefetches that exceed a page
ScriptedAlchemy Sep 19, 2026
8916a5d
Merge pull request #1845 from ScriptedAlchemy/fix/observation-prefetc…
ScriptedAlchemy Sep 19, 2026
31b2add
Merge pull request #1797 from ScriptedAlchemy/fix/master-ci-green-3
ScriptedAlchemy Sep 19, 2026
8babdf3
fix(session-temporal): reconstruct copies without a relation graph
ScriptedAlchemy Sep 19, 2026
dae2203
Merge pull request #1846 from ScriptedAlchemy/fix/refresh-missing-rel…
ScriptedAlchemy Sep 19, 2026
6961c5d
Merge remote-tracking branch 'origin/master' into cursor/trait-call-c…
ScriptedAlchemy Sep 19, 2026
7b61b4b
Merge remote-tracking branch 'origin/cursor/false-edge-bare-receiver-…
ScriptedAlchemy Sep 19, 2026
250e318
chore(code-index): move Rust extraction to extractor.rust.v12
ScriptedAlchemy Sep 19, 2026
e3b140e
style(session-temporal): keep the test module last and rustfmt master
ScriptedAlchemy Sep 19, 2026
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
256 changes: 253 additions & 3 deletions crates/tracedecay-code-extraction/src/rust_extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,70 @@ impl ReceiverTypes {
}
}

/// One type parameter's trait bounds, as a method owner.
///
/// `T: Processor` makes `value.process()` the callee `Processor::process`.
/// Two bounds, or a bound the syntax does not name, stay unresolved so the
/// call is not attached to both traits. This does not rename `self`: #1814
/// still records that binding from the enclosing type.
enum ParamBound {
Unbound,
Unique(String),
Ambiguous,
}

#[derive(Default)]
struct TraitBounds {
parameters: BTreeMap<String, ParamBound>,
}

struct BoundClause {
paths: Vec<String>,
ambiguous: bool,
}

impl TraitBounds {
fn declare(&mut self, name: String) {
self.parameters.insert(name, ParamBound::Unbound);
}

fn knows(&self, name: &str) -> bool {
self.parameters.contains_key(name)
}

fn constrain(&mut self, name: &str, clause: BoundClause) {
if clause.paths.is_empty() && !clause.ambiguous {
return;
}
let Some(slot) = self.parameters.get_mut(name) else {
return;
};
if clause.ambiguous || clause.paths.len() != 1 {
*slot = ParamBound::Ambiguous;
return;
}
let Some(path) = clause.paths.into_iter().next() else {
*slot = ParamBound::Ambiguous;
return;
};
match slot {
ParamBound::Unbound => *slot = ParamBound::Unique(path),
ParamBound::Unique(existing) if existing == &path => {}
ParamBound::Unique(_) | ParamBound::Ambiguous => *slot = ParamBound::Ambiguous,
}
}

/// Replace a written type-parameter name with its unique trait. Any other
/// path, including the enclosing type recorded for `self`, is unchanged.
fn resolve(&self, path: String) -> Option<String> {
match self.parameters.get(path.as_str()) {
Some(ParamBound::Unique(bound)) => Some(bound.clone()),
Some(ParamBound::Ambiguous) => None,
Some(ParamBound::Unbound) | None => Some(path),
}
}
}

/// Internal state used during AST traversal.
///
/// Borrows the caller's source for the lifetime of the walk: copying the
Expand Down Expand Up @@ -1738,6 +1802,17 @@ impl RustExtractor {
node: TsNode<'_>,
function: TsNode<'_>,
receivers: &mut ReceiverTypes,
) {
let bounds = Self::trait_bounds_for(state, function);
Self::collect_receiver_bindings(state, node, function, receivers, &bounds);
}

fn collect_receiver_bindings(
state: &ExtractionState<'_>,
node: TsNode<'_>,
function: TsNode<'_>,
receivers: &mut ReceiverTypes,
bounds: &TraitBounds,
) {
match node.kind() {
"self_parameter" => {
Expand All @@ -1749,14 +1824,14 @@ impl RustExtractor {
if let Some(pattern) = node.child_by_field_name("pattern") {
let type_path = node
.child_by_field_name("type")
.and_then(|ty| Self::stated_type_path(state, ty));
.and_then(|ty| Self::receiver_type_path(state, ty, bounds));
Self::record_receiver_pattern(state, pattern, type_path, receivers);
}
}
"let_declaration" => {
if let Some(pattern) = node.child_by_field_name("pattern") {
let type_path = match node.child_by_field_name("type") {
Some(ty) => Self::stated_type_path(state, ty),
Some(ty) => Self::receiver_type_path(state, ty, bounds),
None => node
.child_by_field_name("value")
.and_then(|value| Self::stated_initializer_type_path(state, value)),
Expand Down Expand Up @@ -1793,7 +1868,7 @@ impl RustExtractor {
let mut cursor = node.walk();
if cursor.goto_first_child() {
loop {
Self::collect_receiver_types(state, cursor.node(), function, receivers);
Self::collect_receiver_bindings(state, cursor.node(), function, receivers, bounds);
if !cursor.goto_next_sibling() {
break;
}
Expand Down Expand Up @@ -1846,10 +1921,185 @@ impl RustExtractor {
"dynamic_type" | "abstract_type" => ty
.child_by_field_name("trait")
.and_then(|inner| Self::stated_type_path(state, inner)),
"higher_ranked_trait_bound" => ty
.child_by_field_name("type")
.and_then(|inner| Self::stated_type_path(state, inner)),
// `impl Trait + 'a` still names that trait. Two nominals do not.
"bounded_type" => Self::unique_sum_type_path(state, ty),
_ => None,
}
}

/// A parameter type, with a type parameter replaced by its unique trait
/// bound. `Self` is left as #1814 mapped it: the enclosing type, not the
/// trait the parameter happens to implement.
fn receiver_type_path(
state: &ExtractionState<'_>,
ty: TsNode<'_>,
bounds: &TraitBounds,
) -> Option<String> {
if Self::annotation_is_self(state, ty) {
return Self::enclosing_receiver_type(state);
}
bounds.resolve(Self::stated_type_path(state, ty)?)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the method's declaring trait, not the sole bound

A sole bound does not imply that every method callable on the parameter is declared by that trait. For example, with trait Processor: Helper {} and fn f<T: Processor>(x: &T) { x.help(); }, Rust resolves help to Helper::help, but this substitution emits Processor::help; the exact owner-path resolver therefore cannot create the call edge to Helper::help. Blanket extension traits have the same issue. Preserve ambiguity or account for supertraits/method ownership instead of treating the unique written bound as the callee owner.

AGENTS.md reference: AGENTS.md:L9-L12

Useful? React with 👍 / 👎.

}

fn annotation_is_self(state: &ExtractionState<'_>, ty: TsNode<'_>) -> bool {
match ty.kind() {
"type_identifier" => state.node_text(ty) == "Self",
"reference_type" => ty
.child_by_field_name("type")
.is_some_and(|inner| Self::annotation_is_self(state, inner)),
_ => false,
}
}

fn unique_sum_type_path(state: &ExtractionState<'_>, ty: TsNode<'_>) -> Option<String> {
let mut found = None;
let mut cursor = ty.walk();
if !cursor.goto_first_child() {
return None;
}
loop {
let child = cursor.node();
if child.is_named() {
let path = match child.kind() {
"lifetime" | "use_bounds" => None,
"bounded_type" => Self::unique_sum_type_path(state, child),
_ => Self::stated_type_path(state, child),
};
match path {
None if matches!(child.kind(), "lifetime" | "use_bounds") => {}
None => return None,
Some(path) => {
if found.replace(path).is_some() {
return None;
}
}
}
}
if !cursor.goto_next_sibling() {
break;
}
}
found
}

fn trait_bounds_for(state: &ExtractionState<'_>, function: TsNode<'_>) -> TraitBounds {
let mut ancestors = Vec::new();
let mut current = function.parent();
while let Some(node) = current {
if matches!(node.kind(), "function_item" | "function_signature_item") {
break;
}
if matches!(node.kind(), "impl_item" | "trait_item") {
ancestors.push(node);
}
current = node.parent();
}
ancestors.reverse();
let mut bounds = TraitBounds::default();
for item in ancestors {
Self::absorb_generic_bounds(state, item, &mut bounds);
}
Self::absorb_generic_bounds(state, function, &mut bounds);
bounds
}

fn absorb_generic_bounds(
state: &ExtractionState<'_>,
item: TsNode<'_>,
bounds: &mut TraitBounds,
) {
if let Some(parameters) = item.child_by_field_name("type_parameters") {
let mut cursor = parameters.walk();
if cursor.goto_first_child() {
loop {
let child = cursor.node();
if child.kind() == "type_parameter"
&& let Some(name_node) = child.child_by_field_name("name")
{
let name = state.node_text(name_node).to_owned();
bounds.declare(name.clone());
if let Some(clause) = child.child_by_field_name("bounds") {
bounds.constrain(&name, Self::trait_bound_clause(state, clause));
}
}
if !cursor.goto_next_sibling() {
break;
}
}
}
}
let Some(where_clause) = Self::child_of_kind(item, "where_clause") else {
return;
};
let mut cursor = where_clause.walk();
if !cursor.goto_first_child() {
return;
}
loop {
let child = cursor.node();
if child.kind() == "where_predicate"
&& let Some(left) = child.child_by_field_name("left")
&& left.kind() == "type_identifier"
{
let name = state.node_text(left);
if bounds.knows(name)
&& let Some(clause) = child.child_by_field_name("bounds")
{
bounds.constrain(name, Self::trait_bound_clause(state, clause));
}
}
if !cursor.goto_next_sibling() {
break;
}
}
}

fn trait_bound_clause(state: &ExtractionState<'_>, bounds: TsNode<'_>) -> BoundClause {
let mut clause = BoundClause {
paths: Vec::new(),
ambiguous: false,
};
let mut cursor = bounds.walk();
if !cursor.goto_first_child() {
return clause;
}
loop {
let child = cursor.node();
if child.is_named() {
match child.kind() {
"lifetime" | "use_bounds" | "removed_trait_bound" => {}
_ => match Self::stated_type_path(state, child) {
Some(path) => clause.paths.push(path),
None => clause.ambiguous = true,
},
}
}
if !cursor.goto_next_sibling() {
break;
}
}
clause
}

fn child_of_kind<'t>(node: TsNode<'t>, kind: &str) -> Option<TsNode<'t>> {
let mut cursor = node.walk();
if !cursor.goto_first_child() {
return None;
}
loop {
let child = cursor.node();
if child.kind() == kind {
return Some(child);
}
if !cursor.goto_next_sibling() {
return None;
}
}
}

/// The type a `let` initialiser states in syntax: a `T { .. }` literal,
/// optionally behind `?`. Method names are never return-type evidence.
/// Abstain rather than fabricate a receiver type.
Expand Down
87 changes: 87 additions & 0 deletions crates/tracedecay-code-extraction/tests/main/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,93 @@ fn make() -> Vec<i32> { Vec::new() }
);
}

#[test]
fn trait_bound_calls_name_the_trait_without_a_bare_method() {
let source = r#"
trait Processor {
fn process(&self, input: u32) -> u32;
fn via_self(&self, input: u32) -> u32 {
self.process(input)
}
}
trait Other {
fn process(&self, input: u32) -> u32;
}
struct Doubler;
impl Doubler {
fn kick(&self, input: u32) -> u32 {
self.process(input)
}
}
impl Processor for Doubler {
fn process(&self, input: u32) -> u32 {
input * 2
}
}
fn via_dyn(processor: &dyn Processor, input: u32) -> u32 {
processor.process(input)
}
fn via_impl(processor: impl Processor + 'static, input: u32) -> u32 {
processor.process(input)
}
fn via_bound<T: Processor>(processor: &T, input: u32) -> u32 {
processor.process(input)
}
fn via_where<T>(processor: &T, input: u32) -> u32
where
T: Processor,
{
processor.process(input)
}
fn ambiguous<T: Processor + Other>(processor: &T, input: u32) -> u32 {
processor.process(input)
}
"#;
let result = RustExtractor.extract("src/lib.rs", source);
assert!(result.errors.is_empty(), "{:?}", result.errors);
let from = |name: &str| {
let function = result
.nodes
.iter()
.find(|node| {
matches!(node.kind, NodeKind::Function | NodeKind::Method) && node.name == name
})
.unwrap_or_else(|| panic!("{name} is extracted"));
result
.unresolved_refs
.iter()
.filter(|reference| {
reference.reference_kind == EdgeKind::Calls && reference.from_node_id == function.id
})
.map(|reference| reference.reference_name.as_str())
.collect::<Vec<_>>()
};

for owner in ["via_self", "via_dyn", "via_impl", "via_bound", "via_where"] {
let names = from(owner);
assert!(
names.contains(&"Processor::process"),
"{owner} must name the trait callee: {names:?}"
);
assert!(
!names.contains(&"process"),
"{owner} must not reintroduce the bare method name: {names:?}"
);
}
let kick = from("kick");
assert!(
kick.contains(&"Doubler::process") && !kick.contains(&"Processor::process"),
"self in an inherent impl stays the type, not the trait: {kick:?}"
);
assert!(!kick.contains(&"process"), "{kick:?}");
let ambiguous = from("ambiguous");
assert!(
!ambiguous.iter().any(|name| name.contains("::process")),
"two trait bounds must not pick a callee: {ambiguous:?}"
);
assert!(!ambiguous.contains(&"process"), "{ambiguous:?}");
}

#[test]
fn self_receiver_names_carry_module_scope_and_the_outer_as_delimiter() {
let source = r#"
Expand Down
Loading
Loading