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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ or required by that guidance.
in [`docs/equivalence.md`](docs/equivalence.md).
3. **Stable node IDs.** Symbol IDs are
`{kind}:{sha256("{filePath}:{kind}:{name}:{line}").hex[..32]}`; file nodes are
`file:{relative/path}`. Paths use `/`; lines are 1-based. Never change this
formula incidentally.
`file:{relative/path}`. Paths use `/`; lines are 1-based. Within one file's
extraction, a later declaration that collides with an earlier one at a
different column appends `:{column}` (zero-based UTF-16 code units, upstream
#1349), so it no longer overwrites the first. Never change this formula
incidentally.
4. **No AI/vector runtime.** Do not add AI, LLM, embedding, vector-database, or
inference dependencies. `scripts/guardrail.sh` enforces the dependency boundary.
5. **Project containment.** Managed state stays under the selected project index
Expand Down
91 changes: 71 additions & 20 deletions crates/codegraph-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4289,11 +4289,6 @@ fn cmd_callers(
Related::Callers,
file.as_deref(),
)?;
let summaries = report
.union
.iter()
.map(NodeSummary::from)
.collect::<Vec<_>>();
let godot = godot_honesty_for_symbol(&store, &project, &symbol)?;
if json_output {
let targets = report
Expand All @@ -4311,14 +4306,14 @@ fn cmd_callers(
"filteredOut": report.filtered_out,
"note": report.note,
"definitions": related_definitions_json(&report, Related::Callers),
"callers": summaries,
"callers": related_union_json(&report, Related::Callers),
"total": report.total,
"limit": report.limit,
"truncated": report.total > report.limit,
"godotDynamic": godot.as_json(),
}))?;
} else {
print_related_report("Callers", "callers", &symbol, &report);
print_related_report("Callers", "callers", &symbol, &report, Related::Callers);
godot.print_cli(report.union.is_empty());
}
if strict && report.union.is_empty() {
Expand All @@ -4345,11 +4340,6 @@ fn cmd_callees(
Related::Callees,
file.as_deref(),
)?;
let summaries = report
.union
.iter()
.map(NodeSummary::from)
.collect::<Vec<_>>();
if json_output {
let targets = report
.definitions
Expand All @@ -4366,13 +4356,13 @@ fn cmd_callees(
"filteredOut": report.filtered_out,
"note": report.note,
"definitions": related_definitions_json(&report, Related::Callees),
"callees": summaries,
"callees": related_union_json(&report, Related::Callees),
"total": report.total,
"limit": report.limit,
"truncated": report.total > report.limit,
}))?;
} else {
print_related_report("Callees", "callees", &symbol, &report);
print_related_report("Callees", "callees", &symbol, &report, Related::Callees);
}
if strict && report.union.is_empty() {
bail!("codegraph callees: no callees found for \"{symbol}\"");
Expand Down Expand Up @@ -6239,10 +6229,12 @@ fn related_definitions_json(report: &RelatedReport, related: Related) -> Vec<ser
.map(|node| {
let mut value = serde_json::to_value(NodeSummary::from(node))
.expect("node summary serializes");
value
.as_object_mut()
.expect("node summary is object")
.insert("id".to_string(), json!(node.id));
let object = value.as_object_mut().expect("node summary is object");
object.insert("id".to_string(), json!(node.id));
object.insert(
"relationships".to_string(),
json!(relationships(node, &definition.edges, related)),
);
value
})
.collect(),
Expand All @@ -6269,6 +6261,49 @@ enum Related {
Callees,
}

/// The distinct edge kinds that link `node` to the queried symbol, in edge
/// order — `["calls", "instantiates"]` for a caller that both calls and
/// constructs it (upstream #1839).
fn relationships(node: &Node, edges: &[Edge], related: Related) -> Vec<&'static str> {
let mut kinds = Vec::new();
for edge in edges {
let end = match related {
Related::Callers => &edge.source,
Related::Callees => &edge.target,
};
if end == &node.id && !kinds.contains(&edge.kind.as_str()) {
kinds.push(edge.kind.as_str());
}
}
kinds
}

/// The legacy union list with each node's relationships across every
/// definition.
fn related_union_json(report: &RelatedReport, related: Related) -> Vec<serde_json::Value> {
let edges = report
.definitions
.iter()
.flat_map(|definition| definition.edges.iter().cloned())
.collect::<Vec<_>>();
report
.union
.iter()
.map(|node| {
let mut value =
serde_json::to_value(NodeSummary::from(node)).expect("node summary serializes");
value
.as_object_mut()
.expect("node summary is object")
.insert(
"relationships".to_string(),
json!(relationships(node, &edges, related)),
);
value
})
.collect()
}

/// Collected Godot honesty signals for the matched symbols of one query: the
/// runtime-reachability reasons (so "no static callers" is never reported as
/// dead) and the symbols' own `godot:dynamic:` computed call-sites. Empty for
Expand Down Expand Up @@ -7018,7 +7053,13 @@ fn print_related(label: &str, symbol: &str, nodes: &[NodeSummary]) {
}
}

fn print_related_report(title: &str, label: &str, symbol: &str, report: &RelatedReport) {
fn print_related_report(
title: &str,
label: &str,
symbol: &str,
report: &RelatedReport,
related: Related,
) {
if let Some(note) = &report.note {
println!("Note: {note}");
}
Expand Down Expand Up @@ -7057,7 +7098,17 @@ fn print_related_report(title: &str, label: &str, symbol: &str, report: &Related
continue;
}
for node in definition.nodes.iter().take(report.limit) {
println!("{:<12}{}", node.kind, node.name);
// Kinds other than a plain call — `[instantiates]`, `[references]`.
let kinds = relationships(node, &definition.edges, related)
.into_iter()
.filter(|kind| *kind != "calls")
.collect::<Vec<_>>();
let relation = if kinds.is_empty() {
String::new()
} else {
format!(" [{}]", kinds.join(", "))
};
println!("{:<12}{}{relation}", node.kind, node.name);
println!(" {}:{}\n", node.file_path, node.start_line);
}
if definition.nodes.len() > report.limit {
Expand Down
41 changes: 41 additions & 0 deletions crates/codegraph-cli/tests/definition_grouping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,44 @@ fn limits_and_impact_metadata_tell_the_truth() {
assert!(definition["affected"][0]["id"].is_string());
}
}

#[test]
fn callers_report_each_callers_relationship_kinds() {
// Upstream #1839 (CLI): a caller that constructs the symbol is marked, in
// JSON and in text, so it is not read as a plain call.
let dir = TestDir::new();
fs::write(
dir.0.join("widget.ts"),
"export class Widget {}\n\
export function make() { return new Widget(); }\n\
export function use() { return make(); }\n",
)
.unwrap();
let path = dir.0.to_str().unwrap();
let (stdout, stderr, ok) = run(&dir.0, &["init", path]);
assert!(ok, "init failed: stdout={stdout} stderr={stderr}");

let widget = json(&dir.0, &["callers", "Widget", "--path", path, "--json"]);
assert_eq!(widget["callers"][0]["name"], "make");
assert_eq!(
widget["callers"][0]["relationships"],
serde_json::json!(["instantiates"])
);
assert_eq!(
widget["definitions"][0]["callers"][0]["relationships"],
serde_json::json!(["instantiates"])
);
let make = json(&dir.0, &["callers", "make", "--path", path, "--json"]);
assert_eq!(
make["callers"][0]["relationships"],
serde_json::json!(["calls"])
);

let (text, stderr, ok) = run(&dir.0, &["callers", "Widget", "--path", path]);
assert!(ok, "callers failed: {stderr}");
assert!(text.contains("make [instantiates]"), "{text}");
let (text, stderr, ok) = run(&dir.0, &["callers", "make", "--path", path]);
assert!(ok, "callers failed: {stderr}");
assert!(text.contains("use\n"), "{text}");
assert!(!text.contains("[calls]"), "{text}");
}
79 changes: 79 additions & 0 deletions crates/codegraph-core/src/node_id.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Upstream-compatible node and content identifiers.

use std::collections::HashMap;

use sha2::{Digest, Sha256};

use crate::types::NodeKind;
Expand All @@ -19,6 +21,50 @@ pub fn generate_node_id(file_path: &str, kind: NodeKind, name: &str, line: u32)
format!("{}:{}", kind, &hex[..32])
}

/// Per-extraction node identities (upstream `NodeIdAllocator`, #1349).
///
/// A declaration keeps its [`generate_node_id`] identity unless an EARLIER
/// declaration of the same extraction already holds that identity at a
/// different source position — a same-kind, same-name declaration on the same
/// line, such as a getter/setter pair. The later one then appends
/// `:{column}`, its zero-based UTF-16 column, instead of overwriting the first
/// in the store. Revisiting a declaration yields the identity it had.
#[derive(Debug, Default)]
pub struct NodeIdAllocator {
first_columns: HashMap<String, u32>,
}
Comment on lines +32 to +35

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 Apply the collision allocator to custom extractors

The allocator is wired into the generic tree-sitter walker and Liquid only, while custom extractors such as Vue and MyBatis still call generate_node_id/default_node directly. Thus a valid Vue script containing function x(){} function x(){} on one line still emits two identical IDs and one declaration is overwritten, despite extraction version 15 advertising the new collision behavior. Route every per-file declaration path through a shared allocator.

AGENTS.md reference: AGENTS.md:L46-L52

Useful? React with 👍 / 👎.


impl NodeIdAllocator {
/// The identity of the declaration of `kind`/`name` at `line` (1-based)
/// and `utf16_column` (0-based UTF-16 code units).
pub fn generate(
&mut self,
file_path: &str,
kind: NodeKind,
name: &str,
line: u32,
utf16_column: u32,
) -> String {
let id = generate_node_id(file_path, kind, name, line);
let first = *self.first_columns.entry(id.clone()).or_insert(utf16_column);
if first == utf16_column {
id
} else {
format!("{id}:{utf16_column}")
}
}
}

/// Zero-based UTF-16 column of byte offset `byte` in `source` — the column
/// unit upstream's identities use, never tree-sitter's byte column.
pub fn utf16_column(source: &str, byte: usize) -> u32 {
let byte = byte.min(source.len());
let line_start = source[..byte].rfind('\n').map_or(0, |newline| newline + 1);
source
.get(line_start..byte)
.map_or(0, |prefix| prefix.encode_utf16().count() as u32)
}

/// Generate the literal file-node ID used by the upstream tree-sitter extractor.
pub fn file_node_id(file_path: &str) -> String {
format!("file:{file_path}")
Expand Down Expand Up @@ -49,6 +95,39 @@ mod tests {

use super::*;

#[test]
fn collisions_only_suffix_later_distinct_columns() {
// Upstream kernel `collision_only_identity_vectors`.
let mut ids = NodeIdAllocator::default();
let base = generate_node_id("src/a.ts", NodeKind::Function, "foo", 3);
for (column, expected) in [
(2, base.clone()),
(24, format!("{base}:24")),
(48, format!("{base}:48")),
(24, format!("{base}:24")),
(2, base.clone()),
] {
assert_eq!(
ids.generate("src/a.ts", NodeKind::Function, "foo", 3, column),
expected
);
}
assert_eq!(
ids.generate("src/a.ts", NodeKind::Function, "bar", 3, 24),
generate_node_id("src/a.ts", NodeKind::Function, "bar", 3)
);
}

#[test]
fn utf16_columns_count_code_units_from_the_line_start() {
let source = "x\n/* é😀 */ set";
let byte = source.find("set").unwrap();
// `/* ` 3, `é` 1, `😀` 2, ` */ ` 4.
assert_eq!(utf16_column(source, byte), 10);
assert_eq!(utf16_column(source, 0), 0);
assert_eq!(utf16_column("ab", 99), 2);
}

#[derive(Debug, Deserialize)]
struct GoldenNode {
id: String,
Expand Down
Loading
Loading