From 4ca9b989dea8975169d4a54676ba21bb77cf3638 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:25:54 +0800 Subject: [PATCH 1/9] fix(extract): give same-line Vue script functions distinct ids (#1349) The Vue extractor mints its script-block functions itself and still called generate_node_id directly, so two same-name function declarations on one line shared an id and the store kept one row. Upstream reaches the allocator by delegating Vue scripts to its tree-sitter extractor. Route the script functions through NodeIdAllocator, with the block-relative line and UTF-16 column the delegated extractor would use; a non-colliding id is unchanged. --- .../codegraph-cli/tests/same_line_identity.rs | 91 +++++++++++++++++++ crates/codegraph-extract/src/embedded/vue.rs | 8 +- .../codegraph-extract/tests/node_identity.rs | 34 +++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 crates/codegraph-cli/tests/same_line_identity.rs diff --git a/crates/codegraph-cli/tests/same_line_identity.rs b/crates/codegraph-cli/tests/same_line_identity.rs new file mode 100644 index 00000000..fe7ad425 --- /dev/null +++ b/crates/codegraph-cli/tests/same_line_identity.rs @@ -0,0 +1,91 @@ +//! Same-line namesakes survive the store, not just extraction (#1349). +//! +//! Nodes are upserted by id, so two declarations sharing one id leave a single +//! row behind. The Vue extractor mints its script functions itself; this +//! indexes a real project and reads the rows back. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use codegraph_core::IndexPaths; +use codegraph_core::types::NodeKind; +use codegraph_store::Store; + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-cli-same-line-identity-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn cli(args: &[&str]) { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph binary"); + assert!( + output.status.success(), + "codegraph {args:?} failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn store(project: &Path) -> Store { + let paths = IndexPaths::resolve(project, None).expect("resolve index paths"); + Store::open_for_read(&paths, Instant::now() + Duration::from_secs(30), || false) + .expect("open the index for reading") +} + +#[test] +fn same_line_vue_functions_are_two_rows() { + let dir = TestDir::new("vue"); + let project = dir.path.join("proj"); + fs::create_dir_all(&project).unwrap(); + fs::write( + project.join("Widget.vue"), + "\n\n", + ) + .unwrap(); + let p = project.to_str().unwrap(); + cli(&["init", p]); + let rows = |project: &Path| { + let mut rows = store(project) + .all_nodes() + .unwrap() + .into_iter() + .filter(|node| node.kind == NodeKind::Function && node.name == "f") + .map(|node| (node.id, node.start_line, node.start_column)) + .collect::>(); + rows.sort(); + rows + }; + let indexed = rows(&project); + assert_eq!(indexed.len(), 2, "{indexed:#?}"); + assert_ne!(indexed[0].0, indexed[1].0); + cli(&["sync", p]); + assert_eq!(rows(&project), indexed, "sync keeps both rows"); + cli(&["index", "--force", p]); + assert_eq!(rows(&project), indexed, "index --force keeps both rows"); +} diff --git a/crates/codegraph-extract/src/embedded/vue.rs b/crates/codegraph-extract/src/embedded/vue.rs index 90c63150..7bd7e806 100644 --- a/crates/codegraph-extract/src/embedded/vue.rs +++ b/crates/codegraph-extract/src/embedded/vue.rs @@ -1,4 +1,4 @@ -use codegraph_core::node_id::generate_node_id; +use codegraph_core::node_id::{NodeIdAllocator, generate_node_id, utf16_column}; use codegraph_core::types::{ Edge, EdgeKind, ExtractionResult, Language, Node, NodeKind, UnresolvedRef, }; @@ -51,6 +51,8 @@ pub struct VueExtractor<'a> { edges: Vec, unresolved_references: Vec, errors: Vec, + /// Same-line namesakes keep distinct ids across every script block (#1349). + node_ids: NodeIdAllocator, } impl<'a> VueExtractor<'a> { @@ -62,6 +64,7 @@ impl<'a> VueExtractor<'a> { edges: Vec::new(), unresolved_references: Vec::new(), errors: Vec::new(), + node_ids: NodeIdAllocator::default(), } } @@ -204,11 +207,12 @@ impl<'a> VueExtractor<'a> { let start_pos = node.start_position(); let end_pos = node.end_position(); - let id = generate_node_id( + let id = self.node_ids.generate( self.file_path, NodeKind::Function, &name, start_pos.row as u32 + 1, + utf16_column(&block.content, node.start_byte()), ); let qualified_name = format!("{}::{}", self.file_path, name); diff --git a/crates/codegraph-extract/tests/node_identity.rs b/crates/codegraph-extract/tests/node_identity.rs index b8ac6606..0aa8cad5 100644 --- a/crates/codegraph-extract/tests/node_identity.rs +++ b/crates/codegraph-extract/tests/node_identity.rs @@ -127,3 +127,37 @@ fn repeated_same_line_liquid_and_cfml_declarations_survive() { assert_eq!(methods.len(), 2, "{:#?}", cfml.nodes); assert_ne!(methods[0].id, methods[1].id); } + +#[test] +fn same_line_vue_script_functions_survive() { + // The Vue extractor mints its own script-block functions; ids use the + // block-relative line and column, as upstream's delegated extractor does. + let source = + "\n\n"; + let result = extract("Widget.vue", source, Language::Vue); + let functions = named(&result, "f", NodeKind::Function); + assert_eq!(functions.len(), 2, "{:#?}", result.nodes); + let legacy = generate_node_id("Widget.vue", NodeKind::Function, "f", 2); + let block_line = "function f() {} function f() {}"; + let second = utf16_column(block_line, block_line.rfind("function").unwrap()); + let mut ids = functions + .iter() + .map(|node| node.id.clone()) + .collect::>(); + ids.sort(); + assert_eq!(ids, vec![legacy.clone(), format!("{legacy}:{second}")]); + for node in functions { + assert!( + result + .edges + .iter() + .any(|edge| edge.kind == EdgeKind::Contains && edge.target == node.id), + "{} has no contains edge", + node.id + ); + } + assert_eq!( + extract("Widget.vue", source, Language::Vue).nodes, + result.nodes + ); +} From b71172278483e506bb8834022c51ef3486a6876e Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:25:55 +0800 Subject: [PATCH 2/9] fix(extract): keep a store initializer's own calls on the store Since exported stores own their inline actions, the walker skipped the rest of the initializer, so `create(persist(...), { storage: createJSONStorage(...) })` recorded no call to `create`, `persist` or `createJSONStorage`, which the port recorded before. Upstream skips the initializer the same way. Walk the initializer attributed to the store and skip only the extracted action values, so no action's calls are counted twice, and walk the factory closure as the store's body instead of binding a curried `create()(...)` factory as a function named after the store. --- crates/codegraph-extract/src/walker.rs | 77 +++++++++++++-- .../tests/js_store_initializers.rs | 94 +++++++++++++++++++ 2 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 crates/codegraph-extract/tests/js_store_initializers.rs diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 1223bed1..33c20364 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -344,6 +344,19 @@ pub struct TreeSitterWalker<'a, 'tree> { /// Latched once a child would exceed [`MAX_WALK_DEPTH`]. All remaining /// descent stops and `extract` discards the partial graph. walk_aborted: bool, + /// Set while an exported store's initializer is walked for the store's + /// own calls; see [`StoreWalk`]. + store_walk: Option, +} + +/// The parts of a store initializer that its walk must not treat as the +/// store's own code. +struct StoreWalk { + /// The factory closure whose body returns the action object. It is walked + /// as the store's body, never bound as a function named after the store. + factory: Option, + /// The action values that became function nodes of their own. + actions: Vec, } impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { @@ -372,6 +385,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { cfml_consumed_until: 0, walk_depth: 0, walk_aborted: false, + store_walk: None, } } @@ -3113,10 +3127,23 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { // An exported store — `create((set, get) => ({ reset: () => // set({}) }))`, middleware wrappers included — owns the // inline actions of the object its initializer returns - // (upstream #647/#1862). The initializer is not walked as a - // whole: that would re-attribute every action's calls to - // the store itself. - self.extract_js_object_literal_members(actions, owner_id); + // (upstream #647/#1862). + let actions_ids = self.extract_js_object_literal_members(actions, owner_id); + // The rest of the initializer runs when the store is built: + // the `create`/`persist` wrappers, their options, a block + // factory's setup. Those calls stay the store's (#693), as + // they were before the actions became nodes; upstream skips + // the whole initializer here and drops them. Only the + // extracted actions are skipped, so no action's calls are + // counted for the store as well. + let previous = self.store_walk.replace(StoreWalk { + factory: nearest_function_ancestor(actions).map(|factory| factory.id()), + actions: actions_ids, + }); + self.node_stack.push(owner_id.to_string()); + self.visit_function_body(value); + self.node_stack.pop(); + self.store_walk = previous; } else { match declared_id { Some(id) => { @@ -3152,7 +3179,14 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { /// range is inside that holder. Nested objects are deliberately not recursed /// into, which lets resolution distinguish `api.run()` from /// `api.nested.run()` by direct range containment. - fn extract_js_object_literal_members(&mut self, object: SyntaxNode<'tree>, owner_id: &str) { + /// + /// Returns the tree node ids of the extracted member values. + fn extract_js_object_literal_members( + &mut self, + object: SyntaxNode<'tree>, + owner_id: &str, + ) -> Vec { + let mut extracted = Vec::new(); self.node_stack.push(owner_id.to_string()); for member in object.named_children(&mut object.walk()) { if member.kind() == "method_definition" { @@ -3163,6 +3197,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { continue; }; self.extract_function(member, Some(name)); + extracted.push(member.id()); continue; } if member.kind() != "pair" { @@ -3179,6 +3214,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { && let Some(bound) = self.curried_wrapper_bound_name(function) { self.extract_function(function, Some(bound)); + extracted.push(function.id()); } continue; } @@ -3197,8 +3233,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { continue; }; self.extract_function(value, Some(name)); + extracted.push(value.id()); } self.node_stack.pop(); + extracted } fn extract_python_assignment(&mut self, node: SyntaxNode<'tree>) { @@ -4309,6 +4347,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { if self.walk_aborted { return; } + if self + .store_walk + .as_ref() + .is_some_and(|walk| walk.actions.contains(&node.id())) + { + return; + } if self.walk_depth >= MAX_WALK_DEPTH { self.walk_aborted = true; return; @@ -4367,7 +4412,12 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { self.maybe_cpp_construction(node); } - if has_type(self.spec.function_types(), node_type) { + if has_type(self.spec.function_types(), node_type) + && !self + .store_walk + .as_ref() + .is_some_and(|walk| walk.factory == Some(node.id())) + { let nested_name = self.extract_name(node); if !nested_name.is_empty() && nested_name != "" { self.extract_function(node, None); @@ -5231,6 +5281,21 @@ fn initializer_returned_object(call: SyntaxNode<'_>, depth: usize) -> Option) -> Option> { + let mut current = node.parent(); + while let Some(candidate) = current { + if matches!( + candidate.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { + return Some(candidate); + } + current = candidate.parent(); + } + None +} + /// The object literal a function returns: the `=> ({ … })` arrow form or a /// top-level `return { … }` of a block body. fn function_returned_object(function: SyntaxNode<'_>) -> Option> { diff --git a/crates/codegraph-extract/tests/js_store_initializers.rs b/crates/codegraph-extract/tests/js_store_initializers.rs new file mode 100644 index 00000000..3f39e128 --- /dev/null +++ b/crates/codegraph-extract/tests/js_store_initializers.rs @@ -0,0 +1,94 @@ +//! A store initializer's own calls belong to the store (#693, #1862). +//! +//! `export const useStore = create(persist((set) => ({ reset: () => set({}) }), +//! opts))` makes each inline action a function node of its own. The rest of +//! the initializer, the `create`/`persist` wrappers and their options, runs +//! when the store is built, so its calls stay attributed to the store, as they +//! were before the actions became nodes. An action's calls are never counted +//! for the store as well. + +use codegraph_core::types::{EdgeKind, ExtractionResult, Language, NodeKind}; +use codegraph_extract::extract_source; + +fn extract(source: &str) -> ExtractionResult { + let result = extract_source("store.ts", source, Some(Language::TypeScript)); + assert!(result.errors.is_empty(), "{:?}", result.errors); + result +} + +/// Sorted names of the `calls` refs made by the node named by `qualified`. +fn calls_from(result: &ExtractionResult, qualified: &str) -> Vec { + let ids = result + .nodes + .iter() + .filter(|node| node.qualified_name == qualified) + .map(|node| node.id.as_str()) + .collect::>(); + assert_eq!(ids.len(), 1, "{qualified}: {:#?}", result.nodes); + let mut names = result + .unresolved_references + .iter() + .filter(|reference| { + reference.reference_kind == EdgeKind::Calls && reference.from_node_id == ids[0] + }) + .map(|reference| reference.reference_name.clone()) + .collect::>(); + names.sort(); + names +} + +#[test] +fn wrapper_and_option_calls_stay_with_the_store() { + let result = extract( + "import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +export const useStore = create( + persist( + (set, get) => ({ + count: initialCount(), + reset: () => set({ count: 0 }), + bump() { get().reset(); }, + }), + { name: 'app', storage: createJSONStorage(() => localStorage) }, + ), +); +", + ); + assert_eq!( + calls_from(&result, "useStore"), + vec!["create", "createJSONStorage", "initialCount", "persist"] + ); + assert_eq!(calls_from(&result, "useStore::reset"), vec!["set"]); + assert_eq!( + calls_from(&result, "useStore::bump"), + vec!["get", "get().reset"] + ); +} + +#[test] +fn a_curried_store_mints_no_function_for_its_factory() { + let result = extract( + "import { create } from 'zustand'; +interface S { bump(): void } +export const useCurried = create()((set) => { + const seed = loadSeed(); + return { bump: () => set({}) }; +}); +", + ); + assert!( + !result + .nodes + .iter() + .any(|node| node.name == "useCurried" && node.kind == NodeKind::Function), + "{:#?}", + result.nodes + ); + // The outer application is named by its callee text, as any curried + // call is. + assert_eq!( + calls_from(&result, "useCurried"), + vec!["create", "create()", "loadSeed"] + ); + assert_eq!(calls_from(&result, "useCurried::bump"), vec!["set"]); +} From 92ee84cf2b72e9e726c1cabaf4bed6c5f648254e Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:25:55 +0800 Subject: [PATCH 3/9] fix(store): bump the extraction version to 18 Vue same-line ids and store initializer calls change what extraction emits, so an index built at version 17 is reported outdated and re-extracted. --- crates/codegraph-store/src/index_state.rs | 2 +- crates/codegraph-store/tests/index_state.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index bf082c18..c3be5473 100644 --- a/crates/codegraph-store/src/index_state.rs +++ b/crates/codegraph-store/src/index_state.rs @@ -115,7 +115,7 @@ pub const CURRENT_STORAGE_PROTOCOL: u64 = 2; /// The extraction-pipeline version this binary produces. This is the single /// source of truth: no other crate may define its own copy. -pub const CURRENT_EXTRACTION_VERSION: u64 = 17; +pub const CURRENT_EXTRACTION_VERSION: u64 = 18; /// The `project_metadata` key under which a built index records the extraction /// version it was produced with. Single source of truth for the key spelling. diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs index 65e5e7cf..cc6a465e 100644 --- a/crates/codegraph-store/tests/index_state.rs +++ b/crates/codegraph-store/tests/index_state.rs @@ -119,7 +119,7 @@ fn assert_corrupt( fn index_state_constants_and_canonical_payload_are_exact() { /// The extraction version the immediately preceding release wrote. Read from /// `index_state.rs` at implementation time; see plan decision C1 step 0. - const PREVIOUS_EXTRACTION_VERSION: u64 = 16; + const PREVIOUS_EXTRACTION_VERSION: u64 = 17; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); From 35a414c56c0d93aaa3c5e973e68328bc5f5882d3 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:25:55 +0800 Subject: [PATCH 4/9] fix(mcp): claim gap names only when every gap had room for them A gap marker names the indexed symbols it hides only from the budget the file has spare; a name that costs more leaves a bare `... (gap) ...`. The note closing the response still said the gap markers named what was elided, on both tiers. Report a withheld name from the render, and say the markers name only what room allowed when one was withheld. The epilogue reserve holds the longer of the two wordings. --- .../tests/explore_unnamed_gap_note.rs | 140 +++++++++++++++ crates/codegraph-mcp/src/engine.rs | 169 ++++++++++++++---- 2 files changed, 275 insertions(+), 34 deletions(-) create mode 100644 crates/codegraph-cli/tests/explore_unnamed_gap_note.rs diff --git a/crates/codegraph-cli/tests/explore_unnamed_gap_note.rs b/crates/codegraph-cli/tests/explore_unnamed_gap_note.rs new file mode 100644 index 00000000..a7f24825 --- /dev/null +++ b/crates/codegraph-cli/tests/explore_unnamed_gap_note.rs @@ -0,0 +1,140 @@ +//! The trim note never claims a gap marker named what it could not afford to +//! (#1711, #2077). +//! +//! A gap between two rendered windows names the indexed symbols it hides, but +//! only from the budget the file has spare; a name that costs more stays out +//! and the marker is a bare `... (gap) ...`. The note closing the response +//! then said the gap markers named what was elided. Here an index-only helper +//! whose name no budget affords sits between the two windows. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-cli-unnamed-gap-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn run_in(cwd: &Path, args: &[&str]) -> String { + let output = Command::new(bin()) + .args(args) + .current_dir(cwd) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph binary"); + assert!( + output.status.success(), + "{args:?} failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// What follows the last source fence: the epilogue. +fn epilogue_of(text: &str) -> &str { + text.rfind("```").map_or(text, |i| &text[i + 3..]) +} + +/// `src/big.ts`: `alphaEntry` at the top, an index-only helper whose name is +/// longer than any per-file budget, then `betaEntry`, whose body runs `beta_lines` +/// lines. `padding` small files lift the project into the large tiers. +fn project(label: &str, beta_lines: usize, padding: usize) -> (TestDir, PathBuf) { + let dir = TestDir::new(label); + let root = dir.path.join("app"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let helper = format!("helper{}", "X".repeat(8000)); + let mut body: Vec = (1..10).map(|i| format!("// header {i}")).collect(); + body.push("export function alphaEntry(): number {".to_string()); + body.extend((0..19).map(|i| format!(" const a{i} = {i};"))); + body.push(" return betaEntry(1);".to_string()); + body.push("}".to_string()); + while body.len() < 99 { + body.push(format!("// filler {}", body.len() + 1)); + } + body.push(format!("function {helper}(): number {{ return 1; }}")); + while body.len() < 199 { + body.push(format!("// filler {}", body.len() + 1)); + } + body.push("export function betaEntry(v: number): number {".to_string()); + body.extend((0..beta_lines).map(|i| format!(" v = v + {i}; // beta stage {i}"))); + body.push(" return v;".to_string()); + body.push("}".to_string()); + while body.len() < 400 { + body.push(format!("// filler {}", body.len() + 1)); + } + std::fs::write(src.join("big.ts"), body.join("\n")).unwrap(); + if padding > 0 { + let pad = root.join("pad"); + std::fs::create_dir_all(&pad).unwrap(); + for i in 0..padding { + std::fs::write( + pad.join(format!("pad{i}.ts")), + format!("export const pad{i} = {i};\n"), + ) + .unwrap(); + } + } + run_in(&dir.path, &["init", root.to_str().unwrap()]); + (dir, root) +} + +#[test] +fn small_tier_note_does_not_claim_an_unaffordable_gap_name() { + let (_dir, root) = project("small", 19, 0); + let text = run_in(&root, &["explore", "alphaEntry betaEntry"]); + // The fixture does what it is for: both windows render around a bare gap. + assert!(text.contains("export function alphaEntry"), "{text}"); + assert!(text.contains("export function betaEntry"), "{text}"); + assert!(text.contains("... (gap) ..."), "{text}"); + assert!(!text.contains("XXXXXXXX"), "{text}"); + let epilogue = epilogue_of(&text); + assert!( + epilogue.contains("Some file sections were trimmed for size"), + "{epilogue}" + ); + assert!( + !epilogue.contains("Elided symbols are named inside gap markers"), + "{epilogue}" + ); + assert!(epilogue.contains("only where room allowed"), "{epilogue}"); +} + +#[test] +fn large_tier_note_does_not_claim_an_unaffordable_gap_name() { + let (_dir, root) = project("large", 900, 520); + let text = run_in(&root, &["explore", "alphaEntry betaEntry"]); + assert!(text.contains("export function alphaEntry"), "{text}"); + assert!(text.contains("... (gap) ..."), "{text}"); + assert!(!text.contains("XXXXXXXX"), "{text}"); + let epilogue = epilogue_of(&text); + // `betaEntry` is windowed, so the section is reported trimmed. + assert!(epilogue.contains("Verbatim source for"), "{epilogue}"); + assert!(!epilogue.contains("name what was elided"), "{epilogue}"); + assert!(epilogue.contains("name what room allowed"), "{epilogue}"); +} diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index 9093d626..85b65728 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -1389,8 +1389,9 @@ impl CodeGraphEngine { let mut budget_dropped = false; let mut excluded_files: Vec<&String> = Vec::new(); let mut rendered_sources: Vec<(String, usize)> = Vec::new(); - // Every admitted section: `(line index, path, what it elided)`. - let mut sections: Vec<(usize, &String, Vec)> = Vec::new(); + // Every admitted section: `(line index, path, what it elided, whether a + // gap marker had no room for its names)`. + let mut sections: Vec<(usize, &String, Vec, bool)> = Vec::new(); // Named files whose per-file ceiling clipped them, with what a second // render needs: `(line index, path, focuses, exact focus)`. let mut named_clipped: Vec<(usize, &String, Vec, Vec)> = Vec::new(); @@ -1471,7 +1472,12 @@ impl CodeGraphEngine { if rendered.clipped && named { named_clipped.push((lines.len(), file_path, focuses, exact_focus)); } - sections.push((lines.len(), file_path, rendered.elided)); + sections.push(( + lines.len(), + file_path, + rendered.elided, + rendered.names_withheld, + )); lines.push(rendered.section); total_chars += section_len + 1; files_included += 1; @@ -1534,6 +1540,7 @@ impl CodeGraphEngine { lines[*line_index] = grown.section; if let Some(entry) = sections.iter_mut().find(|entry| entry.0 == *line_index) { entry.2 = grown.elided; + entry.3 = grown.names_withheld; } } // Sections missing some of what they set out to deliver, in render @@ -1541,12 +1548,13 @@ impl CodeGraphEngine { // claimed only for sections that are. let trimmed_shown: Vec<(String, Vec)> = sections .iter() - .filter(|(_, _, elided)| !elided.is_empty()) - .map(|(_, path, elided)| ((*path).clone(), elided.clone())) + .filter(|(_, _, elided, _)| !elided.is_empty()) + .map(|(_, path, elided, _)| ((*path).clone(), elided.clone())) .collect(); + let names_withheld = sections.iter().any(|(_, _, _, withheld)| *withheld); let any_file_trimmed = budget_dropped || !trimmed_shown.is_empty() - || sections.iter().any(|(index, _, _)| { + || sections.iter().any(|(index, _, _, _)| { lines[*index].contains("... (gap) ...") || lines[*index].contains("more (signatures elided)") }); @@ -1589,8 +1597,12 @@ impl CodeGraphEngine { // files and symbols is paid only from what the sections and the pointer // list left, and never costs a pointer line (upstream #2077). let completeness_note = if budget.include_completeness_signal { - let candidates = - completeness_notes(files_included, &trimmed_shown, &subgraph.file_order); + let candidates = completeness_notes( + files_included, + &trimmed_shown, + &subgraph.file_order, + names_withheld, + ); let room = completeness_note_floor(max_files) + epilogue_room; candidates .iter() @@ -1605,6 +1617,7 @@ impl CodeGraphEngine { &budget, &completeness_note, any_file_trimmed, + names_withheld, indexed_file_count, )); @@ -1779,6 +1792,7 @@ impl CodeGraphEngine { source_emitted: true, clipped: false, elided: Vec::new(), + names_withheld: false, }; } return ExploreFileRender { @@ -1789,6 +1803,7 @@ impl CodeGraphEngine { clipped: false, // Nothing it set out to deliver was sent (#2077). elided: elided_wanted_spans(&subgraph.wanted_spans_in(file_path), &[]), + names_withheld: false, }; } @@ -1815,6 +1830,7 @@ impl CodeGraphEngine { source_emitted: true, clipped: false, elided: Vec::new(), + names_withheld: false, }; } // (2) Unaffordable but owning NO focus and no exact target ⇒ also @@ -1827,6 +1843,7 @@ impl CodeGraphEngine { source_emitted: true, clipped: false, elided: Vec::new(), + names_withheld: false, }; } // (3) Unaffordable AND owning a focus or an exact target ⇒ fall @@ -1923,6 +1940,7 @@ impl CodeGraphEngine { source_emitted: false, clipped: false, elided: Vec::new(), + names_withheld: false, }; } @@ -2453,7 +2471,7 @@ impl CodeGraphEngine { + GAP_MARKER.len() * parts.len().saturating_sub(1); let spare = ceiling.max(projected).saturating_sub(bare_text); let file_index_nodes = self.store.nodes_by_file_path(file_path).unwrap_or_default(); - let mut file_section = + let (mut file_section, names_withheld) = join_parts_with_named_gaps(file_path, &parts, &file_index_nodes, spare); let clipped = chosen_count < clusters.len() || any_cluster_windowed; if clipped { @@ -2500,6 +2518,7 @@ impl CodeGraphEngine { source_emitted: true, clipped, elided: elided_wanted_spans(&wanted, &delivered), + names_withheld, } } @@ -3753,6 +3772,9 @@ struct ExploreFileRender { /// The symbols this section set out to deliver and did not, measured from /// the ranges it sent (#2077). Empty for a complete section. elided: Vec, + /// Some gap marker stayed bare though it held indexed symbols to name: the + /// names cost more than the file had spare (#1711, #2057). + names_withheld: bool, } impl SourceProbe { @@ -4389,31 +4411,38 @@ fn format_gap_marker(file_path: &str, elided: &[ElidedSymbol]) -> String { /// markers that name what the trim skipped. `spare` is what naming may cost /// beyond bare markers: selection priced every join as bare, so a gap the /// spare cannot cover stays bare rather than pushing source out (#2057). +/// +/// Also reports whether such a gap was left bare, so the trim note does not +/// claim the markers named everything. fn join_parts_with_named_gaps( file_path: &str, parts: &[(usize, usize, String)], nodes: &[Node], mut spare: usize, -) -> String { +) -> (String, bool) { let Some((_, _, first)) = parts.first() else { - return String::new(); + return (String::new(), false); }; let mut out = first.clone(); + let mut withheld = false; for pair in parts.windows(2) { let named = format_gap_marker( file_path, &symbols_between_ranges(nodes, pair[0].1, pair[1].0), ); + // A hole with nothing to name costs nothing extra, so only a gap + // that had names can land here. let extra = named.len() - BARE_GAP_MARKER.len(); if extra <= spare { out.push_str(&named); spare -= extra; } else { out.push_str(BARE_GAP_MARKER); + withheld = true; } out.push_str(&pair[1].2); } - out + (out, withheld) } // === Free-function renderers (1:1 with upstream helpers) ==================== @@ -5413,6 +5442,10 @@ const COMPLETENESS_RULE: &str = "---"; /// loop-determined, so the pre-loop reserve has to assume it. const TRIMMED_NOTE: &str = "> Some file sections were trimmed for size. Elided symbols are named inside gap markers as `name (file:line)` and preferred in the file header — run another `codegraph_explore` (or `codegraph_node`) with those exact names for their source."; +/// [`TRIMMED_NOTE`] when some gap marker had no room for the names it hid: it +/// must not say the markers name the elided symbols. +const TRIMMED_NOTE_PARTIAL: &str = "> Some file sections were trimmed for size. Gap markers name elided symbols as `name (file:line)` only where room allowed, and the file header prefers them — run another `codegraph_explore` (or `codegraph_node`) for their source."; + /// One symbol a file section set out to deliver: a cluster member, or a node /// of a section that renders no source (#2077). Completeness is judged against /// these, not against the file — explore never promises whole files, only the @@ -5530,6 +5563,8 @@ fn complete_source_note(files: &str) -> String { } const TRIMMED_NOTE_WHAT: &str = "gap markers and file headers name what was elided"; +/// [`TRIMMED_NOTE_WHAT`] when some gap marker had no room for its names. +const TRIMMED_NOTE_WHAT_PARTIAL: &str = "gap markers and headers name what room allowed"; const TRIMMED_NOTE_TAIL: &str = "For those, or anything under \"Not shown above\", make ANOTHER codegraph_explore with those exact names instead of reading the files — it returns their source with line numbers."; fn trimmed_note_head(files: &str) -> String { @@ -5538,9 +5573,9 @@ fn trimmed_note_head(files: &str) -> String { /// The least specific trimmed note: it names nothing, so it is the floor the /// epilogue reserve holds. -fn generic_trimmed_note(files: &str) -> String { +fn generic_trimmed_note(files: &str, what: &str) -> String { format!( - "{} Some sections were trimmed for size; {TRIMMED_NOTE_WHAT}. {TRIMMED_NOTE_TAIL}", + "{} Some sections were trimmed for size; {what}. {TRIMMED_NOTE_TAIL}", trimmed_note_head(files) ) } @@ -5551,11 +5586,13 @@ fn generic_trimmed_note(files: &str) -> String { /// note keeps the guarantee that is still true (every block shown is /// verbatim), names the trimmed files and the most relevant elided symbols as /// room allows, and sends the agent to another codegraph_explore for them. It -/// never offers Read. +/// never offers Read. Where a gap marker had no room for its names +/// (`names_withheld`), it says the markers name only what room allowed. fn completeness_notes( files_included: usize, trimmed: &[(String, Vec)], known_paths: &[String], + names_withheld: bool, ) -> Vec { let files = files_wording(files_included); if trimmed.is_empty() { @@ -5603,8 +5640,13 @@ fn completeness_notes( } } } + let what = if names_withheld { + TRIMMED_NOTE_WHAT_PARTIAL + } else { + TRIMMED_NOTE_WHAT + }; let head = trimmed_note_head(&files); - let with_files = format!("{head} Trimmed for size: {trimmed_list}; {TRIMMED_NOTE_WHAT}"); + let with_files = format!("{head} Trimmed for size: {trimmed_list}; {what}"); let mut candidates = Vec::new(); if !names.is_empty() { let named = names @@ -5615,7 +5657,7 @@ fn completeness_notes( candidates.push(format!("{with_files} (e.g. {named}). {TRIMMED_NOTE_TAIL}")); } candidates.push(format!("{with_files}. {TRIMMED_NOTE_TAIL}")); - candidates.push(generic_trimmed_note(&files)); + candidates.push(generic_trimmed_note(&files, what)); candidates } @@ -5631,9 +5673,10 @@ fn completeness_note_floor(max_files: usize) -> usize { ] .iter() .map(|files| { - complete_source_note(files) - .len() - .max(generic_trimmed_note(files).len()) + [TRIMMED_NOTE_WHAT, TRIMMED_NOTE_WHAT_PARTIAL] + .iter() + .map(|what| generic_trimmed_note(files, what).len()) + .fold(complete_source_note(files).len(), usize::max) }) .max() .unwrap_or(0) @@ -5672,6 +5715,7 @@ fn epilogue_note_lines( budget: &ExploreOutputBudget, completeness_note: &str, any_file_trimmed: bool, + names_withheld: bool, file_count: Option, ) -> Vec { let mut lines = Vec::new(); @@ -5680,7 +5724,12 @@ fn epilogue_note_lines( lines.push(completeness_note.to_string()); lines.push(String::new()); } else if any_file_trimmed { - lines.push(TRIMMED_NOTE.to_string()); + let note = if names_withheld { + TRIMMED_NOTE_PARTIAL + } else { + TRIMMED_NOTE + }; + lines.push(note.to_string()); lines.push(String::new()); } if budget.include_budget_note @@ -5704,7 +5753,7 @@ fn joined_line_cost(lines: &[String]) -> usize { /// Every term is the length of a string the epilogue actually emits, evaluated at /// its worst case: the completeness note at the longest length it can fall back /// to, `any_file_trimmed = true` selects the larger of the two mutually exclusive -/// notes, and the unlisted tail is reserved at `file_order` length, which is +/// notes (in the longer of its two wordings), and the unlisted tail is reserved at `file_order` length, which is /// >= any achievable count because every unlisted file came from `file_order`. fn fixed_epilogue_reserve( budget: &ExploreOutputBudget, @@ -5722,7 +5771,19 @@ fn fixed_epilogue_reserve( ]); } let floor_note = "x".repeat(completeness_note_floor(max_files)); - reserve += joined_line_cost(&epilogue_note_lines(budget, &floor_note, true, file_count)); + reserve += [false, true] + .map(|names_withheld| { + joined_line_cost(&epilogue_note_lines( + budget, + &floor_note, + true, + names_withheld, + file_count, + )) + }) + .into_iter() + .max() + .unwrap_or(0); reserve } @@ -5947,12 +6008,21 @@ mod tests { let parts = vec![(7, 33, "head".to_string()), (197, 223, "tail".to_string())]; assert_eq!( join_parts_with_named_gaps("a.ts", &parts, &nodes, usize::MAX), - "head\n\n... (gap: helperMid (a.ts:100)) ...\n\ntail" + ( + "head\n\n... (gap: helperMid (a.ts:100)) ...\n\ntail".to_string(), + false + ) ); assert_eq!( join_parts_with_named_gaps("a.ts", &parts, &nodes, 3), - "head\n\n... (gap) ...\n\ntail", - "names the spare cannot pay for stay out" + ("head\n\n... (gap) ...\n\ntail".to_string(), true), + "names the spare cannot pay for stay out, and that is reported" + ); + // A hole with nothing indexed in it is bare at no cost: nothing withheld. + let quiet = vec![(7, 33, "head".to_string()), (34, 60, "tail".to_string())]; + assert_eq!( + join_parts_with_named_gaps("a.ts", &quiet, &nodes, 0), + ("head\n\n... (gap) ...\n\ntail".to_string(), false) ); let many: Vec = (0..8) @@ -6412,7 +6482,7 @@ mod tests { #[test] fn completeness_is_claimed_only_when_nothing_was_trimmed() { - let notes = completeness_notes(4, &[], &["a.ts".to_string(), "b.ts".to_string()]); + let notes = completeness_notes(4, &[], &["a.ts".to_string(), "b.ts".to_string()], false); assert_eq!(notes.len(), 1); assert!( notes[0].contains("Complete source for 4 files"), @@ -6432,7 +6502,7 @@ mod tests { wanted("helperNobodyAskedFor", 10, 12, 1, "method"), ], )]; - let notes = completeness_notes(3, &trimmed, &[file, "src/a.ts".to_string()]); + let notes = completeness_notes(3, &trimmed, &[file, "src/a.ts".to_string()], false); for note in ¬es { assert!(!note.contains("Complete source"), "{note}"); assert!(note.contains("Verbatim source for 3 files"), "{note}"); @@ -6455,14 +6525,14 @@ mod tests { let one = vec![("a.ts".to_string(), vec![wanted("f", 1, 9, 10, "function")])]; let known = ["a.ts".to_string()]; assert!( - completeness_notes(1, &[], &known)[0] + completeness_notes(1, &[], &known, false)[0] .contains("Complete source for 1 file is included") ); assert!( - completeness_notes(1, &one, &known)[0] + completeness_notes(1, &one, &known, false)[0] .contains("Verbatim source for 1 file is included") ); - for note in completeness_notes(0, &one, &known) { + for note in completeness_notes(0, &one, &known, false) { assert!(!note.contains(" 0 file"), "{note}"); assert!(note.contains("Verbatim source for these files"), "{note}"); } @@ -6477,6 +6547,7 @@ mod tests { "src/common/rpcProtocol.ts".to_string(), "src/node/rpcProtocol.ts".to_string(), ], + false, ); assert!(notes[0].contains("`common/rpcProtocol.ts`"), "{}", notes[0]); } @@ -6508,6 +6579,7 @@ mod tests { 1, &trimmed, &["django/db/models/sql/compiler.py".to_string()], + false, ); assert!( notes[0] @@ -6529,7 +6601,7 @@ mod tests { }) .collect(); let known: Vec = trimmed.iter().map(|(path, _)| path.clone()).collect(); - let notes = completeness_notes(8, &trimmed, &known); + let notes = completeness_notes(8, &trimmed, &known, false); assert_eq!(notes.len(), 3); for pair in notes.windows(2) { assert!(pair[1].len() < pair[0].len(), "{pair:?}"); @@ -6543,8 +6615,37 @@ mod tests { assert!(notes[2].len() < OLD_COMPLETENESS_NOTE.len()); // The reserve covers the longest note the epilogue can fall back to. assert!(completeness_note_floor(8) >= notes[2].len()); - assert!(completeness_note_floor(8) >= completeness_notes(8, &[], &known)[0].len()); - assert!(completeness_note_floor(8) >= completeness_notes(0, &trimmed, &known)[2].len()); + assert!(completeness_note_floor(8) >= completeness_notes(8, &[], &known, false)[0].len()); + assert!( + completeness_note_floor(8) >= completeness_notes(0, &trimmed, &known, false)[2].len() + ); + // The wording for a gap left unnamed is covered by the same reserve, + // and none of its candidates says the markers named what was elided. + let partial = completeness_notes(8, &trimmed, &known, true); + assert_eq!(partial.len(), 3); + assert!(completeness_note_floor(8) >= partial[2].len()); + assert!( + completeness_note_floor(8) >= completeness_notes(0, &trimmed, &known, true)[2].len() + ); + for note in &partial { + assert!(!note.contains("name what was elided"), "{note}"); + assert!(note.contains("name what room allowed"), "{note}"); + } + } + + #[test] + fn the_small_tier_trim_note_claims_names_only_when_every_gap_had_them() { + let budget = crate::explore_budget::get_explore_output_budget(3); + assert!(!budget.include_completeness_signal); + let note = |names_withheld| epilogue_note_lines(&budget, "", true, names_withheld, None); + assert_eq!(note(false)[0], TRIMMED_NOTE); + assert_eq!(note(true)[0], TRIMMED_NOTE_PARTIAL); + assert!(!TRIMMED_NOTE_PARTIAL.contains("Elided symbols are named inside gap markers")); + // The reserve holds whichever wording is longer. + let reserve = fixed_epilogue_reserve(&budget, 0, 6, None); + for withheld in [false, true] { + assert!(reserve >= joined_line_cost(¬e(withheld)), "{withheld}"); + } } #[test] From 60d441e772f4aee6dc529f7a6f1512d4d43a3596 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:32:59 +0800 Subject: [PATCH 5/9] fix(resolve): prefer an explicit default binding over an exported component A default import resolved to the module's first exported component before the binding its `export default NAME` statement names. The React resolver mints component nodes inside ordinary JS/TS modules, so a module exporting `function Screen() { return
}` beside `const Api = { upload }; export default Api` bound `import Api` to `Screen`: `Api.upload()` became a call to `Screen` and lost its `upload` edge. Upstream orders the lookup the same way. Try the stated binding first, then a component (a Svelte or Vue file has no such statement and is its own default export), then the first exported function or class. --- .../codegraph-resolve/src/import_resolver.rs | 24 +++++++++----- .../tests/js_receiver_evidence.rs | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index e5ee6fee..d652dcae 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -1999,11 +1999,15 @@ fn find_exported_symbol( // Direct hit (import-resolver.ts:1829-1853). if want.is_default { - // A component file IS its default export; otherwise the binding an - // `export default NAME` statement names — a function, class, component, - // or a namespace object such as `const Api = { upload }` that is not - // exported at its declaration — beats the first-exported-function - // guess (upstream F13 / `defaultExportBindingNode`). + // The binding an `export default NAME` statement names — a function, + // class, component, or a namespace object such as `const Api = { upload + // }` that is not exported at its declaration — is the default export + // (upstream F13 / `defaultExportBindingNode`). Without one, a component + // file IS its default export, and otherwise the first exported function + // or class is the guess. Upstream tries the component first, but the + // React resolver mints component nodes inside ordinary modules too, and + // one of those is not the module's default export when a statement says + // which binding is. let bound_name = re_exports.iter().find_map(|rex| match rex { ReExport::LocalAlias { exported_name, @@ -2028,10 +2032,12 @@ fn find_exported_symbol( }) .min_by_key(|n| (n.start_line, n.start_column)) }; - if let Some(direct) = nodes_in_file - .iter() - .find(|n| n.is_exported && n.kind == NodeKind::Component) - .or_else(bound) + if let Some(direct) = bound() + .or_else(|| { + nodes_in_file + .iter() + .find(|n| n.is_exported && n.kind == NodeKind::Component) + }) .or_else(|| { nodes_in_file.iter().find(|n| { n.is_exported && matches!(n.kind, NodeKind::Function | NodeKind::Class) diff --git a/crates/codegraph-resolve/tests/js_receiver_evidence.rs b/crates/codegraph-resolve/tests/js_receiver_evidence.rs index 5bf1d9ac..89f7bd11 100644 --- a/crates/codegraph-resolve/tests/js_receiver_evidence.rs +++ b/crates/codegraph-resolve/tests/js_receiver_evidence.rs @@ -1417,3 +1417,36 @@ fn a_default_import_of_a_later_exported_const_finds_that_const() { // first exported function (`unrelated`). assert_eq!(project.callees("consume", None), vec!["useStore::read"]); } + +#[test] +fn an_explicit_default_binding_beats_an_exported_component() { + let project = resolve_project(&files(&[ + ( + "package.json", + "{\"name\":\"app\",\"dependencies\":{\"react\":\"18\"}}", + ), + ( + "src/upload.js", + "export function upload(uri) {\n return uri\n}\n", + ), + ( + "src/screen.js", + "import { upload } from './upload'\n\ + import { memo } from 'react'\n\ + export function Screen() {\n return
\n}\n\ + export const Card = memo(() =>
)\n\ + const Api = {\n upload,\n}\n\ + export default Api\n", + ), + ( + "src/use.js", + "import Api from './screen'\nexport function consume(uri) {\n return Api.upload(uri)\n}\n", + ), + ])); + // The React resolver makes `Screen` and `Card` component nodes, but a + // module's exported component is not its default export: the `export + // default NAME` statement says which binding is. Upstream prefers the + // first exported component, which made `consume` call `Screen` and lose + // its `upload` edge. + assert_eq!(project.callees("consume", None), vec!["upload"]); +} From 72933963d67b4b9ae48d3830afcc04678728d189 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:39:16 +0800 Subject: [PATCH 6/9] docs(upstream): record the retroactive review of the v1.6.1 port Seven merged port PRs were reviewed by kirocodex after the fact. The entry lists each verdict, the four defects #291 fixes (three of them KEEP-RUST divergences), and the findings kept at upstream behavior with upstream's reasons. --- docs/upstream-sync/UPSTREAM.md | 74 +++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/upstream-sync/UPSTREAM.md b/docs/upstream-sync/UPSTREAM.md index 94fe7c36..02da6f72 100644 --- a/docs/upstream-sync/UPSTREAM.md +++ b/docs/upstream-sync/UPSTREAM.md @@ -59,7 +59,11 @@ below remain immutable historical evidence. > - **KEEP-RUST divergences** are recorded row by row in the audit — among them > a parameter-pack constructor declines its overload set (upstream admits it > unbounded), a self-closing `` is never a statement-map root, and a -> `this.a.b.m()` chain keeps its last segment. +> `this.a.b.m()` chain keeps its last segment. Three more came out of the +> retroactive review, in #291: a store initializer's own calls stay with the +> store, an explicit `export default NAME` binding beats an exported +> component, and a trim note never claims a name a gap withheld. See the +> 2026-10-01 retroactive-review entry below. > - The `1.5.0 → 1.6.0` and older caveats below carry forward unchanged. > **1.5.0 → 1.6.0 sync: COMPLETE** (codegraph-rs `v0.48.2` → `v0.50.1`). @@ -126,6 +130,74 @@ below remain immutable historical evidence. ## Sync log +### 2026-10-01 — Retroactive review of the `v1.6.1` port PRs: four port defects FIXED (#291) + +The owner asked for a kirocodex review of the large PRs that merged before +large changes needed one. The goal gate reviewed each merged commit against its +parent at `high` effort, with the PR description as the claims under review. +Then every blocking item was checked against `main` (`c00aa2b`) and upstream +`v1.6.1` (`f4ddf50`). + +| PR | Verdict | Blocking items | Outcome | +| ---- | ------- | -------------- | --------------------------------------------------------------------------- | +| #271 | PASS | 0 | — | +| #275 | PASS | 0 | — | +| #277 | FAIL | 2 | one fixed, one kept at upstream behavior | +| #278 | FAIL | 4 | two fixed, one kept at upstream behavior, one about evidence only | +| #279 | PASS | 0 | its one defect, the full-sync scope, was already fixed by #286 | +| #281 | FAIL | 1 | fixed | +| #282 | FAIL | 3 | all three kept at upstream behavior | + +**Fixed in #291, extraction version 18:** + +- **#1349 gap.** The Vue extractor mints its own script-block functions and + still called `generate_node_id` directly. Two same-name declarations on one + line therefore shared an id, and the store kept one row. Upstream reaches its + allocator by delegating Vue scripts to its tree-sitter extractor. The port now + uses `NodeIdAllocator` there, with the same block-relative line and UTF-16 + column. +- **KEEP-RUST: a store initializer's own calls stay with the store.** After + #277, an exported store's inline actions were function nodes, and the rest of + the initializer went unwalked. `create`, `persist` and their options' calls + were lost; the port had recorded them before #277. Upstream skips the + initializer the same way (`membersExtractedSeparately`). The walk now skips + only the extracted action values, and it treats the factory closure as the + store's body, so a curried `create()(...)` mints no function named after + the store. +- **KEEP-RUST: an explicit `export default NAME` binding beats an exported + component.** The React resolver mints component nodes inside `.js` and `.ts` + modules. Upstream's lookup (`defaultComponent ?? defaultExportBindingNode ?? + defaultFnClass`) bound a default import to the module's first exported + component. In a module exporting `function Screen() { return
}` + beside `const Api = { upload }; export default Api`, `Api.upload()` became a + call to `Screen`, and the `upload` edge was lost. +- **KEEP-RUST: the trim note never claims a name a gap withheld.** A gap marker + names what it hides only from the budget its file has spare. Both tiers' + notes still said the markers named what was elided, and upstream's wording + (`tools.ts:1302`, `:7272`) is the same. When a name was withheld, the note now + says the markers name only what room allowed. The epilogue reserve holds the + longer of the two wordings. + +**Kept at upstream behavior.** Each of these review findings describes +upstream's own design, and in each case the upstream source says why: + +- **#277:** `.getState().member()` binds for a store built by any factory, not + only Zustand's `create`. Upstream's `matchStoreAccessorChain` checks Zustand + provenance only for selectors. The member must still sit in that holder's own + object literal. A provenance gate would also drop `zustand/vanilla`, wrapped + and locally re-exported stores. +- **#278:** an unknown receiver falls back to a project-unique method name + ("Unknown receivers retain the old unique-or-drop discipline"). This + predates #278. +- **#282:** `#if` reads a literal, one `defined()` or a bare name, so a compound + condition is unknown and keeps the call. `#ifndef X` followed by an empty + `#define X` reads as the guard idiom (`guardsItself`). Any `initializer_list` + constructor declines its overload set, because the reference carries arity, + not the brace or paren form. + +The fourth #278 item asked for an exact-head evidence artifact. #278 merged +after CI run `36798356332` passed on a tree-identical head. + ### 2026-10-01 — `v0.52.0` RELEASED: #286, #288 and #289 shipped Release-please PR [#287](https://github.com/sunerpy/codegraph-rust/pull/287) From 60366e4be720fbb3863eec8f852cd06ab9c88828 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:47:45 +0800 Subject: [PATCH 7/9] fix(mcp): never claim that gap markers name every elided symbol The previous commit switched the trim note to a hedged wording only when an inner gap marker withheld a name. The tail marker after a dropped or windowed cluster names nothing either, and a dropped file appears only in the pointer list, so the unhedged wording was still false on most trimmed responses. Use the hedged wording on both tiers always, and drop the withheld-name plumbing that chose between the two. Cover the tail marker on both tiers. --- .../tests/explore_unnamed_gap_note.rs | 60 +++++- crates/codegraph-mcp/src/engine.rs | 186 +++++------------- 2 files changed, 105 insertions(+), 141 deletions(-) diff --git a/crates/codegraph-cli/tests/explore_unnamed_gap_note.rs b/crates/codegraph-cli/tests/explore_unnamed_gap_note.rs index a7f24825..22cb079c 100644 --- a/crates/codegraph-cli/tests/explore_unnamed_gap_note.rs +++ b/crates/codegraph-cli/tests/explore_unnamed_gap_note.rs @@ -60,15 +60,20 @@ fn epilogue_of(text: &str) -> &str { text.rfind("```").map_or(text, |i| &text[i + 3..]) } -/// `src/big.ts`: `alphaEntry` at the top, an index-only helper whose name is -/// longer than any per-file budget, then `betaEntry`, whose body runs `beta_lines` -/// lines. `padding` small files lift the project into the large tiers. -fn project(label: &str, beta_lines: usize, padding: usize) -> (TestDir, PathBuf) { +/// `src/big.ts`: `alphaEntry` at the top, then (when `helper_x` is given) an +/// index-only helper named `helper` plus that many X's, then `betaEntry`, whose +/// body runs `beta_lines` lines. `padding` small files lift the project into +/// the large tiers. +fn project( + label: &str, + helper_x: Option, + beta_lines: usize, + padding: usize, +) -> (TestDir, PathBuf) { let dir = TestDir::new(label); let root = dir.path.join("app"); let src = root.join("src"); std::fs::create_dir_all(&src).unwrap(); - let helper = format!("helper{}", "X".repeat(8000)); let mut body: Vec = (1..10).map(|i| format!("// header {i}")).collect(); body.push("export function alphaEntry(): number {".to_string()); body.extend((0..19).map(|i| format!(" const a{i} = {i};"))); @@ -77,7 +82,10 @@ fn project(label: &str, beta_lines: usize, padding: usize) -> (TestDir, PathBuf) while body.len() < 99 { body.push(format!("// filler {}", body.len() + 1)); } - body.push(format!("function {helper}(): number {{ return 1; }}")); + if let Some(x) = helper_x { + let helper = format!("helper{}", "X".repeat(x)); + body.push(format!("function {helper}(): number {{ return 1; }}")); + } while body.len() < 199 { body.push(format!("// filler {}", body.len() + 1)); } @@ -106,7 +114,8 @@ fn project(label: &str, beta_lines: usize, padding: usize) -> (TestDir, PathBuf) #[test] fn small_tier_note_does_not_claim_an_unaffordable_gap_name() { - let (_dir, root) = project("small", 19, 0); + // No per-file budget affords an 8000-character name. + let (_dir, root) = project("small", Some(8000), 19, 0); let text = run_in(&root, &["explore", "alphaEntry betaEntry"]); // The fixture does what it is for: both windows render around a bare gap. assert!(text.contains("export function alphaEntry"), "{text}"); @@ -127,7 +136,7 @@ fn small_tier_note_does_not_claim_an_unaffordable_gap_name() { #[test] fn large_tier_note_does_not_claim_an_unaffordable_gap_name() { - let (_dir, root) = project("large", 900, 520); + let (_dir, root) = project("large", Some(8000), 900, 520); let text = run_in(&root, &["explore", "alphaEntry betaEntry"]); assert!(text.contains("export function alphaEntry"), "{text}"); assert!(text.contains("... (gap) ..."), "{text}"); @@ -138,3 +147,38 @@ fn large_tier_note_does_not_claim_an_unaffordable_gap_name() { assert!(!epilogue.contains("name what was elided"), "{epilogue}"); assert!(epilogue.contains("name what room allowed"), "{epilogue}"); } + +/// The section a windowed `betaEntry` leaves ends in a bare tail marker: what +/// it cut is never named in a gap marker, whatever the budget. Nothing indexed +/// sits between the two functions, so no inner gap had a name to withhold. +fn assert_trailing_bare_gap(text: &str) { + assert!(text.contains("export function betaEntry"), "{text}"); + assert!(text.contains("... (gap) ...\n```"), "{text}"); + assert!(!text.contains("(gap: "), "{text}"); +} + +#[test] +fn small_tier_note_does_not_claim_names_for_a_trailing_gap() { + let (_dir, root) = project("small-tail", None, 900, 0); + let text = run_in(&root, &["explore", "alphaEntry betaEntry"]); + assert_trailing_bare_gap(&text); + let epilogue = epilogue_of(&text); + assert!( + epilogue.contains("Some file sections were trimmed for size"), + "{epilogue}" + ); + assert!( + !epilogue.contains("Elided symbols are named inside gap markers"), + "{epilogue}" + ); +} + +#[test] +fn large_tier_note_does_not_claim_names_for_a_trailing_gap() { + let (_dir, root) = project("large-tail", None, 900, 520); + let text = run_in(&root, &["explore", "alphaEntry betaEntry"]); + assert_trailing_bare_gap(&text); + let epilogue = epilogue_of(&text); + assert!(epilogue.contains("Verbatim source for"), "{epilogue}"); + assert!(!epilogue.contains("name what was elided"), "{epilogue}"); +} diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index 85b65728..6904119c 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -1389,9 +1389,8 @@ impl CodeGraphEngine { let mut budget_dropped = false; let mut excluded_files: Vec<&String> = Vec::new(); let mut rendered_sources: Vec<(String, usize)> = Vec::new(); - // Every admitted section: `(line index, path, what it elided, whether a - // gap marker had no room for its names)`. - let mut sections: Vec<(usize, &String, Vec, bool)> = Vec::new(); + // Every admitted section: `(line index, path, what it elided)`. + let mut sections: Vec<(usize, &String, Vec)> = Vec::new(); // Named files whose per-file ceiling clipped them, with what a second // render needs: `(line index, path, focuses, exact focus)`. let mut named_clipped: Vec<(usize, &String, Vec, Vec)> = Vec::new(); @@ -1472,12 +1471,7 @@ impl CodeGraphEngine { if rendered.clipped && named { named_clipped.push((lines.len(), file_path, focuses, exact_focus)); } - sections.push(( - lines.len(), - file_path, - rendered.elided, - rendered.names_withheld, - )); + sections.push((lines.len(), file_path, rendered.elided)); lines.push(rendered.section); total_chars += section_len + 1; files_included += 1; @@ -1540,7 +1534,6 @@ impl CodeGraphEngine { lines[*line_index] = grown.section; if let Some(entry) = sections.iter_mut().find(|entry| entry.0 == *line_index) { entry.2 = grown.elided; - entry.3 = grown.names_withheld; } } // Sections missing some of what they set out to deliver, in render @@ -1548,13 +1541,12 @@ impl CodeGraphEngine { // claimed only for sections that are. let trimmed_shown: Vec<(String, Vec)> = sections .iter() - .filter(|(_, _, elided, _)| !elided.is_empty()) - .map(|(_, path, elided, _)| ((*path).clone(), elided.clone())) + .filter(|(_, _, elided)| !elided.is_empty()) + .map(|(_, path, elided)| ((*path).clone(), elided.clone())) .collect(); - let names_withheld = sections.iter().any(|(_, _, _, withheld)| *withheld); let any_file_trimmed = budget_dropped || !trimmed_shown.is_empty() - || sections.iter().any(|(index, _, _, _)| { + || sections.iter().any(|(index, _, _)| { lines[*index].contains("... (gap) ...") || lines[*index].contains("more (signatures elided)") }); @@ -1597,12 +1589,8 @@ impl CodeGraphEngine { // files and symbols is paid only from what the sections and the pointer // list left, and never costs a pointer line (upstream #2077). let completeness_note = if budget.include_completeness_signal { - let candidates = completeness_notes( - files_included, - &trimmed_shown, - &subgraph.file_order, - names_withheld, - ); + let candidates = + completeness_notes(files_included, &trimmed_shown, &subgraph.file_order); let room = completeness_note_floor(max_files) + epilogue_room; candidates .iter() @@ -1617,7 +1605,6 @@ impl CodeGraphEngine { &budget, &completeness_note, any_file_trimmed, - names_withheld, indexed_file_count, )); @@ -1792,7 +1779,6 @@ impl CodeGraphEngine { source_emitted: true, clipped: false, elided: Vec::new(), - names_withheld: false, }; } return ExploreFileRender { @@ -1803,7 +1789,6 @@ impl CodeGraphEngine { clipped: false, // Nothing it set out to deliver was sent (#2077). elided: elided_wanted_spans(&subgraph.wanted_spans_in(file_path), &[]), - names_withheld: false, }; } @@ -1830,7 +1815,6 @@ impl CodeGraphEngine { source_emitted: true, clipped: false, elided: Vec::new(), - names_withheld: false, }; } // (2) Unaffordable but owning NO focus and no exact target ⇒ also @@ -1843,7 +1827,6 @@ impl CodeGraphEngine { source_emitted: true, clipped: false, elided: Vec::new(), - names_withheld: false, }; } // (3) Unaffordable AND owning a focus or an exact target ⇒ fall @@ -1940,7 +1923,6 @@ impl CodeGraphEngine { source_emitted: false, clipped: false, elided: Vec::new(), - names_withheld: false, }; } @@ -2471,7 +2453,7 @@ impl CodeGraphEngine { + GAP_MARKER.len() * parts.len().saturating_sub(1); let spare = ceiling.max(projected).saturating_sub(bare_text); let file_index_nodes = self.store.nodes_by_file_path(file_path).unwrap_or_default(); - let (mut file_section, names_withheld) = + let mut file_section = join_parts_with_named_gaps(file_path, &parts, &file_index_nodes, spare); let clipped = chosen_count < clusters.len() || any_cluster_windowed; if clipped { @@ -2518,7 +2500,6 @@ impl CodeGraphEngine { source_emitted: true, clipped, elided: elided_wanted_spans(&wanted, &delivered), - names_withheld, } } @@ -3772,9 +3753,6 @@ struct ExploreFileRender { /// The symbols this section set out to deliver and did not, measured from /// the ranges it sent (#2077). Empty for a complete section. elided: Vec, - /// Some gap marker stayed bare though it held indexed symbols to name: the - /// names cost more than the file had spare (#1711, #2057). - names_withheld: bool, } impl SourceProbe { @@ -4411,38 +4389,31 @@ fn format_gap_marker(file_path: &str, elided: &[ElidedSymbol]) -> String { /// markers that name what the trim skipped. `spare` is what naming may cost /// beyond bare markers: selection priced every join as bare, so a gap the /// spare cannot cover stays bare rather than pushing source out (#2057). -/// -/// Also reports whether such a gap was left bare, so the trim note does not -/// claim the markers named everything. fn join_parts_with_named_gaps( file_path: &str, parts: &[(usize, usize, String)], nodes: &[Node], mut spare: usize, -) -> (String, bool) { +) -> String { let Some((_, _, first)) = parts.first() else { - return (String::new(), false); + return String::new(); }; let mut out = first.clone(); - let mut withheld = false; for pair in parts.windows(2) { let named = format_gap_marker( file_path, &symbols_between_ranges(nodes, pair[0].1, pair[1].0), ); - // A hole with nothing to name costs nothing extra, so only a gap - // that had names can land here. let extra = named.len() - BARE_GAP_MARKER.len(); if extra <= spare { out.push_str(&named); spare -= extra; } else { out.push_str(BARE_GAP_MARKER); - withheld = true; } out.push_str(&pair[1].2); } - (out, withheld) + out } // === Free-function renderers (1:1 with upstream helpers) ==================== @@ -5440,11 +5411,12 @@ const COMPLETENESS_RULE: &str = "---"; /// The trim note emitted at tiers that gate the completeness signal OFF. It is /// only reached when a file was actually trimmed, but `any_file_trimmed` is /// loop-determined, so the pre-loop reserve has to assume it. -const TRIMMED_NOTE: &str = "> Some file sections were trimmed for size. Elided symbols are named inside gap markers as `name (file:line)` and preferred in the file header — run another `codegraph_explore` (or `codegraph_node`) with those exact names for their source."; - -/// [`TRIMMED_NOTE`] when some gap marker had no room for the names it hid: it -/// must not say the markers name the elided symbols. -const TRIMMED_NOTE_PARTIAL: &str = "> Some file sections were trimmed for size. Gap markers name elided symbols as `name (file:line)` only where room allowed, and the file header prefers them — run another `codegraph_explore` (or `codegraph_node`) for their source."; +/// +/// It never says every elided symbol is named: a gap marker names what it +/// hides only from the budget its file has spare (#2057), the tail marker after +/// a dropped or windowed cluster names nothing, and a dropped file appears only +/// in the pointer list. Upstream's wording claims the markers name them all. +const TRIMMED_NOTE: &str = "> Some file sections were trimmed for size. Gap markers name elided symbols as `name (file:line)` only where room allowed, and the file header prefers them — run another `codegraph_explore` (or `codegraph_node`) for their source."; /// One symbol a file section set out to deliver: a cluster member, or a node /// of a section that renders no source (#2077). Completeness is judged against @@ -5562,9 +5534,8 @@ fn complete_source_note(files: &str) -> String { ) } -const TRIMMED_NOTE_WHAT: &str = "gap markers and file headers name what was elided"; -/// [`TRIMMED_NOTE_WHAT`] when some gap marker had no room for its names. -const TRIMMED_NOTE_WHAT_PARTIAL: &str = "gap markers and headers name what room allowed"; +/// Never "name what was elided", as upstream says: see [`TRIMMED_NOTE`]. +const TRIMMED_NOTE_WHAT: &str = "gap markers and headers name what room allowed"; const TRIMMED_NOTE_TAIL: &str = "For those, or anything under \"Not shown above\", make ANOTHER codegraph_explore with those exact names instead of reading the files — it returns their source with line numbers."; fn trimmed_note_head(files: &str) -> String { @@ -5573,9 +5544,9 @@ fn trimmed_note_head(files: &str) -> String { /// The least specific trimmed note: it names nothing, so it is the floor the /// epilogue reserve holds. -fn generic_trimmed_note(files: &str, what: &str) -> String { +fn generic_trimmed_note(files: &str) -> String { format!( - "{} Some sections were trimmed for size; {what}. {TRIMMED_NOTE_TAIL}", + "{} Some sections were trimmed for size; {TRIMMED_NOTE_WHAT}. {TRIMMED_NOTE_TAIL}", trimmed_note_head(files) ) } @@ -5586,13 +5557,11 @@ fn generic_trimmed_note(files: &str, what: &str) -> String { /// note keeps the guarantee that is still true (every block shown is /// verbatim), names the trimmed files and the most relevant elided symbols as /// room allows, and sends the agent to another codegraph_explore for them. It -/// never offers Read. Where a gap marker had no room for its names -/// (`names_withheld`), it says the markers name only what room allowed. +/// never offers Read. fn completeness_notes( files_included: usize, trimmed: &[(String, Vec)], known_paths: &[String], - names_withheld: bool, ) -> Vec { let files = files_wording(files_included); if trimmed.is_empty() { @@ -5640,13 +5609,8 @@ fn completeness_notes( } } } - let what = if names_withheld { - TRIMMED_NOTE_WHAT_PARTIAL - } else { - TRIMMED_NOTE_WHAT - }; let head = trimmed_note_head(&files); - let with_files = format!("{head} Trimmed for size: {trimmed_list}; {what}"); + let with_files = format!("{head} Trimmed for size: {trimmed_list}; {TRIMMED_NOTE_WHAT}"); let mut candidates = Vec::new(); if !names.is_empty() { let named = names @@ -5657,7 +5621,7 @@ fn completeness_notes( candidates.push(format!("{with_files} (e.g. {named}). {TRIMMED_NOTE_TAIL}")); } candidates.push(format!("{with_files}. {TRIMMED_NOTE_TAIL}")); - candidates.push(generic_trimmed_note(&files, what)); + candidates.push(generic_trimmed_note(&files)); candidates } @@ -5673,10 +5637,9 @@ fn completeness_note_floor(max_files: usize) -> usize { ] .iter() .map(|files| { - [TRIMMED_NOTE_WHAT, TRIMMED_NOTE_WHAT_PARTIAL] - .iter() - .map(|what| generic_trimmed_note(files, what).len()) - .fold(complete_source_note(files).len(), usize::max) + complete_source_note(files) + .len() + .max(generic_trimmed_note(files).len()) }) .max() .unwrap_or(0) @@ -5715,7 +5678,6 @@ fn epilogue_note_lines( budget: &ExploreOutputBudget, completeness_note: &str, any_file_trimmed: bool, - names_withheld: bool, file_count: Option, ) -> Vec { let mut lines = Vec::new(); @@ -5724,12 +5686,7 @@ fn epilogue_note_lines( lines.push(completeness_note.to_string()); lines.push(String::new()); } else if any_file_trimmed { - let note = if names_withheld { - TRIMMED_NOTE_PARTIAL - } else { - TRIMMED_NOTE - }; - lines.push(note.to_string()); + lines.push(TRIMMED_NOTE.to_string()); lines.push(String::new()); } if budget.include_budget_note @@ -5753,7 +5710,7 @@ fn joined_line_cost(lines: &[String]) -> usize { /// Every term is the length of a string the epilogue actually emits, evaluated at /// its worst case: the completeness note at the longest length it can fall back /// to, `any_file_trimmed = true` selects the larger of the two mutually exclusive -/// notes (in the longer of its two wordings), and the unlisted tail is reserved at `file_order` length, which is +/// notes, and the unlisted tail is reserved at `file_order` length, which is /// >= any achievable count because every unlisted file came from `file_order`. fn fixed_epilogue_reserve( budget: &ExploreOutputBudget, @@ -5771,19 +5728,7 @@ fn fixed_epilogue_reserve( ]); } let floor_note = "x".repeat(completeness_note_floor(max_files)); - reserve += [false, true] - .map(|names_withheld| { - joined_line_cost(&epilogue_note_lines( - budget, - &floor_note, - true, - names_withheld, - file_count, - )) - }) - .into_iter() - .max() - .unwrap_or(0); + reserve += joined_line_cost(&epilogue_note_lines(budget, &floor_note, true, file_count)); reserve } @@ -6008,21 +5953,12 @@ mod tests { let parts = vec![(7, 33, "head".to_string()), (197, 223, "tail".to_string())]; assert_eq!( join_parts_with_named_gaps("a.ts", &parts, &nodes, usize::MAX), - ( - "head\n\n... (gap: helperMid (a.ts:100)) ...\n\ntail".to_string(), - false - ) + "head\n\n... (gap: helperMid (a.ts:100)) ...\n\ntail" ); assert_eq!( join_parts_with_named_gaps("a.ts", &parts, &nodes, 3), - ("head\n\n... (gap) ...\n\ntail".to_string(), true), - "names the spare cannot pay for stay out, and that is reported" - ); - // A hole with nothing indexed in it is bare at no cost: nothing withheld. - let quiet = vec![(7, 33, "head".to_string()), (34, 60, "tail".to_string())]; - assert_eq!( - join_parts_with_named_gaps("a.ts", &quiet, &nodes, 0), - ("head\n\n... (gap) ...\n\ntail".to_string(), false) + "head\n\n... (gap) ...\n\ntail", + "names the spare cannot pay for stay out" ); let many: Vec = (0..8) @@ -6482,7 +6418,7 @@ mod tests { #[test] fn completeness_is_claimed_only_when_nothing_was_trimmed() { - let notes = completeness_notes(4, &[], &["a.ts".to_string(), "b.ts".to_string()], false); + let notes = completeness_notes(4, &[], &["a.ts".to_string(), "b.ts".to_string()]); assert_eq!(notes.len(), 1); assert!( notes[0].contains("Complete source for 4 files"), @@ -6502,7 +6438,7 @@ mod tests { wanted("helperNobodyAskedFor", 10, 12, 1, "method"), ], )]; - let notes = completeness_notes(3, &trimmed, &[file, "src/a.ts".to_string()], false); + let notes = completeness_notes(3, &trimmed, &[file, "src/a.ts".to_string()]); for note in ¬es { assert!(!note.contains("Complete source"), "{note}"); assert!(note.contains("Verbatim source for 3 files"), "{note}"); @@ -6525,14 +6461,14 @@ mod tests { let one = vec![("a.ts".to_string(), vec![wanted("f", 1, 9, 10, "function")])]; let known = ["a.ts".to_string()]; assert!( - completeness_notes(1, &[], &known, false)[0] + completeness_notes(1, &[], &known)[0] .contains("Complete source for 1 file is included") ); assert!( - completeness_notes(1, &one, &known, false)[0] + completeness_notes(1, &one, &known)[0] .contains("Verbatim source for 1 file is included") ); - for note in completeness_notes(0, &one, &known, false) { + for note in completeness_notes(0, &one, &known) { assert!(!note.contains(" 0 file"), "{note}"); assert!(note.contains("Verbatim source for these files"), "{note}"); } @@ -6547,7 +6483,6 @@ mod tests { "src/common/rpcProtocol.ts".to_string(), "src/node/rpcProtocol.ts".to_string(), ], - false, ); assert!(notes[0].contains("`common/rpcProtocol.ts`"), "{}", notes[0]); } @@ -6579,7 +6514,6 @@ mod tests { 1, &trimmed, &["django/db/models/sql/compiler.py".to_string()], - false, ); assert!( notes[0] @@ -6601,7 +6535,7 @@ mod tests { }) .collect(); let known: Vec = trimmed.iter().map(|(path, _)| path.clone()).collect(); - let notes = completeness_notes(8, &trimmed, &known, false); + let notes = completeness_notes(8, &trimmed, &known); assert_eq!(notes.len(), 3); for pair in notes.windows(2) { assert!(pair[1].len() < pair[0].len(), "{pair:?}"); @@ -6615,36 +6549,22 @@ mod tests { assert!(notes[2].len() < OLD_COMPLETENESS_NOTE.len()); // The reserve covers the longest note the epilogue can fall back to. assert!(completeness_note_floor(8) >= notes[2].len()); - assert!(completeness_note_floor(8) >= completeness_notes(8, &[], &known, false)[0].len()); - assert!( - completeness_note_floor(8) >= completeness_notes(0, &trimmed, &known, false)[2].len() - ); - // The wording for a gap left unnamed is covered by the same reserve, - // and none of its candidates says the markers named what was elided. - let partial = completeness_notes(8, &trimmed, &known, true); - assert_eq!(partial.len(), 3); - assert!(completeness_note_floor(8) >= partial[2].len()); - assert!( - completeness_note_floor(8) >= completeness_notes(0, &trimmed, &known, true)[2].len() - ); - for note in &partial { - assert!(!note.contains("name what was elided"), "{note}"); - assert!(note.contains("name what room allowed"), "{note}"); - } + assert!(completeness_note_floor(8) >= completeness_notes(8, &[], &known)[0].len()); + assert!(completeness_note_floor(8) >= completeness_notes(0, &trimmed, &known)[2].len()); } #[test] - fn the_small_tier_trim_note_claims_names_only_when_every_gap_had_them() { - let budget = crate::explore_budget::get_explore_output_budget(3); - assert!(!budget.include_completeness_signal); - let note = |names_withheld| epilogue_note_lines(&budget, "", true, names_withheld, None); - assert_eq!(note(false)[0], TRIMMED_NOTE); - assert_eq!(note(true)[0], TRIMMED_NOTE_PARTIAL); - assert!(!TRIMMED_NOTE_PARTIAL.contains("Elided symbols are named inside gap markers")); - // The reserve holds whichever wording is longer. - let reserve = fixed_epilogue_reserve(&budget, 0, 6, None); - for withheld in [false, true] { - assert!(reserve >= joined_line_cost(¬e(withheld)), "{withheld}"); + fn trim_notes_never_claim_every_elided_symbol_is_named() { + let trimmed = vec![( + "src/a.ts".to_string(), + vec![wanted("f", 1, 9, 10, "function")], + )]; + let mut notes = completeness_notes(1, &trimmed, &["src/a.ts".to_string()]); + notes.push(TRIMMED_NOTE.to_string()); + for note in ¬es { + assert!(!note.contains("name what was elided"), "{note}"); + assert!(!note.contains("Elided symbols are named"), "{note}"); + assert!(note.contains("room allowed"), "{note}"); } } From 1127f07f5dc77eb57385514b2c79d38b674a04ed Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:48:25 +0800 Subject: [PATCH 8/9] docs(upstream): describe the trim-note fix as it now stands The trim note now hedges on both tiers always, rather than only when an inner gap withheld a name. --- docs/upstream-sync/UPSTREAM.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/upstream-sync/UPSTREAM.md b/docs/upstream-sync/UPSTREAM.md index 02da6f72..f619b278 100644 --- a/docs/upstream-sync/UPSTREAM.md +++ b/docs/upstream-sync/UPSTREAM.md @@ -62,8 +62,8 @@ below remain immutable historical evidence. > `this.a.b.m()` chain keeps its last segment. Three more came out of the > retroactive review, in #291: a store initializer's own calls stay with the > store, an explicit `export default NAME` binding beats an exported -> component, and a trim note never claims a name a gap withheld. See the -> 2026-10-01 retroactive-review entry below. +> component, and a trim note never claims that gap markers name every elided +> symbol. See the 2026-10-01 retroactive-review entry below. > - The `1.5.0 → 1.6.0` and older caveats below carry forward unchanged. > **1.5.0 → 1.6.0 sync: COMPLETE** (codegraph-rs `v0.48.2` → `v0.50.1`). @@ -171,12 +171,12 @@ Then every blocking item was checked against `main` (`c00aa2b`) and upstream component. In a module exporting `function Screen() { return
}` beside `const Api = { upload }; export default Api`, `Api.upload()` became a call to `Screen`, and the `upload` edge was lost. -- **KEEP-RUST: the trim note never claims a name a gap withheld.** A gap marker - names what it hides only from the budget its file has spare. Both tiers' - notes still said the markers named what was elided, and upstream's wording - (`tools.ts:1302`, `:7272`) is the same. When a name was withheld, the note now - says the markers name only what room allowed. The epilogue reserve holds the - longer of the two wordings. +- **KEEP-RUST: the trim note never claims that gap markers name every elided + symbol.** A gap marker names what it hides only from the budget its file has + spare. The tail marker after a dropped or windowed cluster names nothing, and + a dropped file appears only in the pointer list. Both tiers' notes, like + upstream's (`tools.ts:1302`, `:7272`), still said the markers named what was + elided. They now say the markers name what room allowed. **Kept at upstream behavior.** Each of these review findings describes upstream's own design, and in each case the upstream source says why: From 59a252011aa7ff9a5a519a1244332a020b728852 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 21:56:19 +0800 Subject: [PATCH 9/9] fix(extract): satisfy clippy's nonminimal_bool on the pinned toolchain Rust 1.96's clippy, which CI pins, rejects !opt.is_some_and(p); write it as opt.is_none_or(!p). --- crates/codegraph-extract/src/walker.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 33c20364..2df3504d 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -4413,10 +4413,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } if has_type(self.spec.function_types(), node_type) - && !self + && self .store_walk .as_ref() - .is_some_and(|walk| walk.factory == Some(node.id())) + .is_none_or(|walk| walk.factory != Some(node.id())) { let nested_name = self.extract_name(node); if !nested_name.is_empty() && nested_name != "" {