Skip to content
Open
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
3 changes: 1 addition & 2 deletions crates/tracedecay-cli/src/tool_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ use args::{
};
#[cfg(test)]
use args::{
edit_distance, finalize_arrays, parse_invocation_with_stdin,
parse_whole_payload_invocation_with_stdin,
finalize_arrays, parse_invocation_with_stdin, parse_whole_payload_invocation_with_stdin,
};
#[cfg(test)]
use serde_json::Map;
Expand Down
25 changes: 5 additions & 20 deletions crates/tracedecay-cli/src/tool_command/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::path::PathBuf;
use serde_json::{Map, Value};

use tracedecay_domain::errors::{Result, TraceDecayError};
use tracedecay_domain::text::edit_distance_within;
use tracedecay_mcp::tool_errors::TOOL_ARGUMENTS_INVALID;
use tracedecay_mcp::{ToolDefinition, resolve_property_schema, short_tool_name};

Expand Down Expand Up @@ -543,8 +544,10 @@ fn nearest_by_edit_distance(
let max_distance = max_typo_distance(target);
candidates
.into_iter()
.map(|candidate| (edit_distance(target, &candidate), candidate))
.filter(|(distance, _)| *distance <= max_distance)
.filter_map(|candidate| {
edit_distance_within(target, &candidate, max_distance)
.map(|distance| (distance, candidate))
})
.min_by_key(|(distance, _)| *distance)
.map(|(_, candidate)| candidate)
}
Expand All @@ -564,24 +567,6 @@ pub(crate) fn nearest_tool_name(canonical: &str, defs: &[ToolDefinition]) -> Opt
)
}

/// Classic two-row Levenshtein distance; property and tool names are short so
/// the quadratic cost is irrelevant.
pub(super) fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}

/// A `-flag` (single dash) token whose name matches a known property is a
/// typo'd flag, not a positional. Returns the kebab-case flag name to suggest.
fn single_dash_flag_typo(raw: &str, props: &Map<String, Value>) -> Option<String> {
Expand Down
7 changes: 0 additions & 7 deletions crates/tracedecay-cli/src/tool_command/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -903,13 +903,6 @@ fn unknown_tool_suggestion_finds_nearest_name() {
assert_eq!(suggestion.as_deref(), Some("dead_code"));
}

#[test]
fn edit_distance_basics() {
assert_eq!(edit_distance("limit", "limit"), 0);
assert_eq!(edit_distance("limt", "limit"), 1);
assert_eq!(edit_distance("", "abc"), 3);
}

#[test]
fn validation_skips_opaque_schemas() {
// A definition without properties must be treated as opaque: no unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,10 @@ pub struct PrimitiveNotFoundV1 {
pub reason_code: String,
pub node_id: String,
pub message: String,
/// Nearest served symbols by edit distance to the requested id, by id,
/// simple name, or qualified name.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggestions: Vec<PrimitiveSymbolLocationV1>,
/// The worktree verdict a served graph read opens with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freshness: Option<PrimitiveSearchFreshnessV1>,
Expand Down
43 changes: 41 additions & 2 deletions crates/tracedecay-domain/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,52 @@ pub fn utf8_prefix_at_or_before(text: &str, max_bytes: usize) -> &str {
&text[..text.floor_char_boundary(max_bytes)]
}

/// Levenshtein distance between `left` and `right` when it is at most
/// `limit`, counted in characters. `None` means more than `limit` edits.
#[must_use]
pub fn edit_distance_within(left: &str, right: &str, limit: usize) -> Option<usize> {
let left: Vec<char> = left.chars().collect();
let right: Vec<char> = right.chars().collect();
if left.len().abs_diff(right.len()) > limit {
return None;
}
let mut previous: Vec<usize> = (0..=right.len()).collect();
let mut current = vec![0; right.len() + 1];
for (left_index, left_character) in left.iter().enumerate() {
current[0] = left_index + 1;
for (right_index, right_character) in right.iter().enumerate() {
current[right_index + 1] = (previous[right_index]
+ usize::from(left_character != right_character))
.min(previous[right_index + 1] + 1)
.min(current[right_index] + 1);
}
// A row's minimum never decreases in later rows.
if current.iter().min().is_some_and(|minimum| *minimum > limit) {
return None;
}
std::mem::swap(&mut previous, &mut current);
}
Some(previous[right.len()]).filter(|distance| *distance <= limit)
}

#[cfg(test)]
mod tests {
use super::{
blank_json_comments, collapse_whitespace, fold_control_characters, forward_slash_text,
utf8_prefix_at_or_before,
blank_json_comments, collapse_whitespace, edit_distance_within, fold_control_characters,
forward_slash_text, utf8_prefix_at_or_before,
};

#[test]
fn edit_distance_within_counts_character_edits_up_to_the_limit() {
assert_eq!(edit_distance_within("limit", "limit", 0), Some(0));
assert_eq!(edit_distance_within("limt", "limit", 1), Some(1));
assert_eq!(edit_distance_within("", "abc", 3), Some(3));
assert_eq!(edit_distance_within("", "abc", 2), None);
assert_eq!(edit_distance_within("kitten", "sitting", 3), Some(3));
assert_eq!(edit_distance_within("kitten", "sitting", 2), None);
assert_eq!(edit_distance_within("caf\u{e9}", "cafe", 1), Some(1));
}

#[test]
fn json_comments_become_spaces_and_keep_offsets_and_strings() {
let text = "{\n // a\n \"url\": \"http://x/y\", /* b\n c */ \"n\": 1\n}";
Expand Down
66 changes: 60 additions & 6 deletions crates/tracedecay-mcp/src/handlers/graph/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,16 @@ pub use verified::{
required_graph_metadata, single_graph_adjacency_batch, traverse_verified_neighbors,
};

use std::cell::RefCell;
use std::collections::BTreeMap;

use tracedecay_contracts::retrieval::PrimitiveNotFoundV1;
use tracedecay_domain::SymbolOccurrenceId;
use tracedecay_domain::errors::{Result, TraceDecayError};
use tracedecay_domain::text::edit_distance_within;
use tracedecay_graph_query::VerifiedGraphQuery;

use primitive_surface::symbol_location;

use crate::{ToolResult, text_tool_result};

Expand All @@ -51,18 +59,64 @@ pub(super) fn require_positive_depth(max_depth: u32) -> Result<()> {
Ok(())
}

pub fn node_not_found(node_id: &str) -> Result<ToolResult> {
not_found_tool_result(&node_not_found_result(node_id))
}
const NODE_SUGGESTION_LIMIT: usize = 5;

pub(crate) fn node_not_found_result(node_id: &str) -> PrimitiveNotFoundV1 {
PrimitiveNotFoundV1 {
/// Not-found answer carrying the served symbols nearest to the requested
/// id: a typo'd or truncated occurrence id, or a name passed as an id.
pub(crate) fn node_not_found_result(
graph: &VerifiedGraphQuery,
node_id: &str,
occurrence: &SymbolOccurrenceId,
) -> Result<PrimitiveNotFoundV1> {
let query = occurrence.as_str();
let max_distance = (query.chars().count() / 3).clamp(1, 3);
let nearest = RefCell::new(BTreeMap::<(usize, String), SymbolOccurrenceId>::new());
graph.find_symbols(
&|candidate, binding, metadata| {
// Only symbols `symbol_location` can render; unbound edge targets
// carry neither extraction metadata nor a logical file.
let Some(metadata) = metadata else {
return false;
};
if binding
.and_then(|binding| binding.logical_path.as_ref())
.is_none()
{
return false;
}
let distance = [
candidate.as_str(),
metadata.simple_name.as_str(),
metadata.qualified_name.as_str(),
]
.into_iter()
.filter_map(|text| edit_distance_within(query, text, max_distance))
.min();
if let Some(distance) = distance {
let mut nearest = nearest.borrow_mut();
nearest.insert((distance, candidate.as_str().to_owned()), candidate.clone());
if nearest.len() > NODE_SUGGESTION_LIMIT {
nearest.pop_last();
}
}
false
},
1,
)?;
let mut suggestions = Vec::new();
for occurrence in nearest.into_inner().into_values() {
if let Some(symbol) = graph.symbol_summary(&occurrence)? {
suggestions.push(symbol_location(&symbol)?);
}
}
Ok(PrimitiveNotFoundV1 {
status: "not_found".to_owned(),
reason_code: "node_not_found".to_owned(),
node_id: node_id.to_owned(),
message: format!("Node not found: {node_id}"),
suggestions,
freshness: None,
}
})
}

pub fn not_found_tool_result(output: &PrimitiveNotFoundV1) -> Result<ToolResult> {
Expand Down
8 changes: 6 additions & 2 deletions crates/tracedecay-mcp/src/handlers/graph/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ pub async fn compute_impact(
if graph.symbol_summary(&occurrence)?.is_none() {
return Ok(graph_tool_completion(
GraphToolResultV1::Impact(ImpactResultV1::NotFound(node_not_found_result(
graph,
&request.node_id,
))),
&occurrence,
)?)),
Vec::new(),
));
}
Expand Down Expand Up @@ -163,8 +165,10 @@ pub async fn compute_node(
}
None => Ok(graph_tool_completion(
GraphToolResultV1::Node(NodeResultV1::NotFound(Box::new(node_not_found_result(
graph,
&request.node_id,
)))),
&occurrence,
)?))),
Vec::new(),
)),
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay-mcp/src/handlers/graph/rename_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ pub async fn compute_rename_preview(
let Some(node) = graph.symbol_summary(&occurrence)? else {
return Ok(graph_tool_completion(
GraphToolResultV1::RenamePreview(RenamePreviewPrimitiveOutcomeV1::NotFound(
node_not_found_result(&request.node_id),
node_not_found_result(graph, &request.node_id, &occurrence)?,
)),
Vec::new(),
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,38 @@ async fn impact_reports_callers_by_depth_and_refuses_invalid_requests() {
"an unknown seed reports not-found like tracedecay_node: {unknown}"
);

let misspelled = handle_real_server_tool_call_raw(
&server,
"tracedecay_impact",
json!({ "node_id": "calee", "format": "json" }),
)
.await;
let misspelled_text = misspelled
.pointer("/result/content/0/text")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("impact response missing text: {misspelled}"));
assert_eq!(
serde_json::from_str::<Value>(misspelled_text).expect("not-found JSON"),
json!({
"freshness": {"state": "fresh"},
"status": "not_found",
"reason_code": "node_not_found",
"node_id": "calee",
"message": "Node not found: calee",
"suggestions": [{
"node_id": ids["callee"],
"name": "callee",
"qualified_name": "src/lib.rs::callee",
"kind": "function",
"file": "src/lib.rs",
"start_line": 3,
"end_line": 5,
"unavailable_fields": ["attrs_start_line"]
}]
}),
"a misspelled seed suggests the symbol it names: {misspelled}"
);

assert_refused(
&server,
json!({ "node_id": " ", "format": "json" }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ async fn tracedecay_node_reports_declared_symbols_and_typed_refusals() {
fixture.harness.shutdown().await;
}

/// An unknown id answers with the nearest served symbols, whether the caller
/// typo'd the occurrence id or passed a misspelled name in its place.
#[tokio::test]
async fn tracedecay_node_suggests_the_nearest_symbols_for_an_unknown_id() {
let fixture = production_composition_fixture_with_sources(|project| {
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/lib.rs"), SOURCE).unwrap();
})
.await;
let server = fixture
.harness
.server(&fixture.project_root)
.expect("production node server");
wait_for_current_graph(&server).await;
let fetch_id = occurrence_id(&server, "fetch_value", "function").await;
let fetch_location = json!({
"node_id": fetch_id,
"name": "fetch_value",
"qualified_name": "src/lib.rs::fetch_value",
"kind": "function",
"file": "src/lib.rs",
"start_line": 2,
"end_line": 11,
"unavailable_fields": ["attrs_start_line"]
});

let mut typo_id = fetch_id.clone();
let last = typo_id.pop().expect("occurrence id");
typo_id.push(if last == '0' { '1' } else { '0' });
for unknown in [typo_id.as_str(), "fetch_valu"] {
let missing = node_call(&server, json!({"node_id": unknown})).await;
assert_eq!(missing["result"]["isError"], true, "{missing}");
assert_eq!(
parse_json(&tool_text(&missing)),
json!({
"freshness": {"state": "fresh"},
"status": "not_found",
"reason_code": "node_not_found",
"node_id": unknown,
"message": format!("Node not found: {unknown}"),
"suggestions": [fetch_location]
})
);
}

fixture.harness.shutdown().await;
}

fn fetch_details(id: &str) -> Value {
details(
id,
Expand Down
16 changes: 8 additions & 8 deletions plugin/chatgpt-extension/embedded/app.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion plugin/chatgpt-extension/embedded/server.mjs

Large diffs are not rendered by default.

Loading
Loading