From 87e79c6ee83de65ee7ad504ff5e84c7336bf8eb3 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 27 Sep 2026 13:10:17 +0000 Subject: [PATCH] fix(dead-code): exclude annotated Tauri commands (#1543) (#2019) Rust command attributes were not retained as decorators, so runtime entry points could appear as dead code. Preserve tauri::command metadata in both native and fallback extraction and reuse the existing decorated exclusion. Add SQLite regression coverage and native/fallback parity checks, including negative cases. Co-authored-by: Claude Opus 5.5 --- __tests__/kernel-rustlang-parity.test.ts | 22 +++++++++ __tests__/tauri-command-dead-code.test.ts | 60 +++++++++++++++++++++++ codegraph-kernel/src/rustlang.rs | 30 ++++++++++-- src/extraction/tree-sitter.ts | 15 ++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 __tests__/tauri-command-dead-code.test.ts diff --git a/__tests__/kernel-rustlang-parity.test.ts b/__tests__/kernel-rustlang-parity.test.ts index 7ba73feda..3380ae67a 100644 --- a/__tests__/kernel-rustlang-parity.test.ts +++ b/__tests__/kernel-rustlang-parity.test.ts @@ -91,6 +91,28 @@ describe.skipIf(!kernelBuilt)('kernel Rust extraction parity', () => { expect(viaWasm.nodes.length).toBeGreaterThanOrEqual(minNodes); } + it('preserves Tauri command attributes without treating comments or strings as registrations', () => { + const source = ` +#[tauri::command] +fn port() -> u16 { 4000 } +#[tauri :: command(rename_all = "snake_case")] +/* between attributes */ +#[allow(dead_code)] +pub async fn settings() {} +// #[tauri::command] +fn ordinary() {} +const TEXT: &str = r#"#[tauri::command]"#; +fn after_string() {} +#[other::command] +fn unrelated() {} +#[tauri::command] +fn outer() { fn inner() {} } +fn next() {} +`; + assertParity('commands.rs', source); + assertParity('commands.rs', source.replace(/\n/g, '\r\n')); + }); + it('torture fixture: impl/trait quirks, use bindings, chains, fn-refs, value-refs, route macros', () => { const file = path.join(FIXTURE_DIR, 'torture.rs'); assertParity('fixtures/torture.rs', fs.readFileSync(file, 'utf8'), 20); diff --git a/__tests__/tauri-command-dead-code.test.ts b/__tests__/tauri-command-dead-code.test.ts new file mode 100644 index 000000000..fd9159996 --- /dev/null +++ b/__tests__/tauri-command-dead-code.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import CodeGraph from '../src/index'; +import { buildDeadCodeReport } from '../src/graph/dead-code'; +import { buildDeadCode } from '../src/ui-server/api/deadcode'; + +let root: string; +let cg: CodeGraph | undefined; +afterEach(() => { + cg?.close(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Tauri command dead-code exclusion (#1543)', () => { + it('counts commands as decorated entry points while retaining unannotated helpers', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-tauri-dead-')); + fs.writeFileSync(path.join(root, 'commands.rs'), ` +pub fn reached() {} +#[tauri::command] +fn get_mcp_port() -> u16 { 4000 } +#[tauri :: command(rename_all = "snake_case")] +// A comment between attributes and the function is allowed. +#[allow(non_snake_case)] +pub async fn readSettings() {} +fn unused_helper() {} +// #[tauri::command] +fn comment_only() {} +#[other::command] +fn unrelated_attribute() {} +const TEXT: &str = r#"#[tauri::command]"#; +fn string_only() {} +#[tauri::command] +fn outer_command() { fn nested_helper() {} } +fn next_function() {} +`); + fs.writeFileSync(path.join(root, 'main.rs'), 'mod commands;\nfn main() { commands::reached(); }\n'); + cg = CodeGraph.initSync(root); + await cg.indexAll(); + const nodes = cg.getNodesInFile('commands.rs'); + for (const name of ['get_mcp_port', 'readSettings', 'outer_command']) { + expect(nodes.find(n => n.name === name)?.decorators, name).toEqual(['tauri::command']); + } + // Rust export coverage is incomplete; includeExported exercises the actual + // decorator rule rather than letting exportsUnknown hide the regression. + const report = buildDeadCodeReport(cg, { includeExported: true }); + expect(report.excluded.decorated).toBe(3); + const wire = buildDeadCode(cg, root, new URLSearchParams('exported=1')); + expect(wire.excluded).toContainEqual({ + reason: 'decorated', count: 3, label: 'carrying a decorator, so a framework registers them', + }); + expect(buildDeadCodeReport(cg, { includeExported: true, readSource: null }).excluded.decorated).toBe(3); + expect(report.entries.map(e => e.node.name)).toEqual(expect.arrayContaining([ + 'unused_helper', 'comment_only', 'unrelated_attribute', 'string_only', 'nested_helper', 'next_function', + ])); + expect(report.entries.some(e => ['get_mcp_port', 'readSettings', 'outer_command'].includes(e.node.name))).toBe(false); + }); +}); diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index c9b246de3..4515d3469 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -76,6 +76,7 @@ struct Scope { #[derive(Default)] struct Extra { docstring: Option, + decorators: Option, signature: Option, return_type: Option, qualified_name: Option, @@ -281,6 +282,7 @@ impl<'t> Walker<'t> { let id_ref = self.arena.put(&id); let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref()); let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref()); + let decorators_ref = opt_str(&mut self.arena, extra.decorators.as_deref()); let ret_ref = opt_str(&mut self.arena, extra.return_type.as_deref()); let row = self.tables.push_node(&NodeRow { kind: node_kind_index(kind).unwrap(), @@ -295,7 +297,7 @@ impl<'t> Walker<'t> { id: id_ref, docstring: doc_ref, signature: sig_ref, - decorators: NONE_STR, + decorators: decorators_ref, type_parameters: NONE_STR, return_type: ret_ref, extra_json: NONE_STR, @@ -515,7 +517,31 @@ impl<'t> Walker<'t> { return; } + // Match the wasm walker's sibling-attribute handling for Tauri commands. + let mut decorators = None; + if node.kind() == "function_item" { + let mut sibling = node.prev_named_sibling(); + while let Some(item) = sibling { + match item.kind() { + "line_comment" | "block_comment" => {}, + "attribute_item" => { + let name = item.named_child(0).and_then(|a| a.named_child(0)); + if let Some(name) = name { + let text: String = self.src[name.byte_range()].chars() + .filter(|c| !c.is_whitespace()).collect(); + if text == "tauri::command" { + decorators = Some("tauri::command".to_string()); + break; + } + } + }, + _ => break, + } + sibling = item.prev_named_sibling(); + } + } let extra = Extra { + decorators, docstring: preceding_docstring(node, self.src), signature: self.signature_of(node), visibility: Some(self.visibility_of(node)), @@ -558,8 +584,6 @@ impl<'t> Walker<'t> { } self.extract_type_annotations(node, row); - // extractDecoratorsFor: rust attribute_items are siblings, not - // decorator/annotation/attribute node types — complete no-op. self.stack.push(Scope { row, kind, name }); if let Some(body) = node.child_by_field_name("body") { self.visit_function_body(body); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index dd36407f9..fb2dfd097 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -5313,6 +5313,21 @@ export class TreeSitterExtractor { * (most non-decorator-using languages), the function is a no-op. */ private extractDecoratorsFor(declNode: SyntaxNode, decoratedId: string): void { + // Rust outer attributes are siblings, not children of the function. + // Preserve Tauri's runtime registration for the dead-code decorator rule. + if (this.language === 'rust' && declNode.type === 'function_item') { + for (let sibling = declNode.previousNamedSibling; sibling; sibling = sibling.previousNamedSibling) { + if (sibling.type === 'line_comment' || sibling.type === 'block_comment') continue; + if (sibling.type !== 'attribute_item') break; + const attribute = sibling.namedChild(0); + const name = attribute?.namedChild(0)?.text.replace(/\s/g, ''); + if (name === 'tauri::command') { + const decorated = this.nodes.find(n => n.id === decoratedId); + if (decorated) decorated.decorators = ['tauri::command']; + break; + } + } + } const consider = (n: SyntaxNode | null): void => { if (!n) return; // Solidity `modifier_invocation` (unique to that grammar) sits