diff --git a/CHANGELOG.md b/CHANGELOG.md index c750ed86e3..899b565659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Features + +- Connect imported Tauri `invoke` calls with literal command names to decorated, registered Rust handlers in conventional `src-tauri` applications. Edges retain invoke and handler-registration evidence. + ### Fixes - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. diff --git a/__tests__/tauri-ipc.test.ts b/__tests__/tauri-ipc.test.ts new file mode 100644 index 0000000000..b553de5fc5 --- /dev/null +++ b/__tests__/tauri-ipc.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import CodeGraph from '../src/index'; +import { hasSynthesisPattern } from '../src/resolution/callback-synthesizer'; + +const roots: string[] = []; const graphs: CodeGraph[] = []; +function write(root: string, file: string, source: string) { const target = path.join(root, file); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, source); } +const backend = '#[tauri::command]\nfn greet() {}\n#[tauri::command]\nfn unregistered() {}\nfn not_command() {}\nfn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![greet, not_command]).run(); }\n'; +async function fixture(files: Record) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tauri-')); roots.push(root); + for (const [file, source] of Object.entries(files)) write(root, file, source); + const graph = CodeGraph.initSync(root); graphs.push(graph); await graph.indexAll(); await graph.resolveReferences(); + return { root, graph }; +} +function links(graph: CodeGraph) { return graph.getNodesByName('greet').flatMap(node => graph.getCallers(node.id)).filter(row => row.edge.metadata?.synthesizedBy === 'tauri-command'); } +afterEach(() => { for (const graph of graphs.splice(0)) graph.close(); for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); +describe('Tauri IPC links', () => { + it('links direct, aliased and namespace imports to registered decorated commands', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport async function click() { return invoke("greet"); }', 'src/b.ts': 'import { invoke as callRust } from "@tauri-apps/api/tauri";\nexport function otherClick() { return callRust("greet"); }', 'src/c.ts': 'import * as core from "@tauri-apps/api/core";\nexport function namespaceClick() { return core.invoke("greet"); }' }); + const rows = links(graph); expect(rows.map(row => row.node.name).sort()).toEqual(['click', 'namespaceClick', 'otherClick']); + expect(rows.every(row => row.edge.provenance === 'heuristic')).toBe(true); + expect(rows.every(row => row.edge.metadata?.registeredAt === 'src-tauri/src/lib.rs:6')).toBe(true); + expect(rows.every(row => row.edge.line === 2)).toBe(true); + }); + it('rejects shadowed imports, dynamic keys, wrappers and absent registrations', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'function parameter(invoke: (key: string) => unknown) { return invoke("greet"); }', + 'function destructured() { const { invoke } = other; return invoke("greet"); }', + 'function dynamic(key: string) { return invoke(key); }', + 'function absent() { invoke("unregistered"); invoke("not_command"); invoke("plugin:store|get"); }', + 'function literal() { return "invoke(\\"greet\\")"; }', + '// invoke("greet")', + ].join('\n'), 'src/unrelated.ts': 'import { invoke } from "./wrapper"; export function wrapperClick() { return invoke("greet"); }' }); + expect(links(graph)).toEqual([]); + }); + it('maps a qualified module command to its external bare key', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': 'mod commands; fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![crate::commands::greet]); }', 'src-tauri/src/commands.rs': '#[tauri::command]\npub fn greet() {}', 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' }); + expect(links(graph)).toHaveLength(1); + }); + it('does not connect comment/string decoys or a detached macro', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': '#[tauri::command]\nfn greet() {}\nfn run() { let detached = tauri::generate_handler![greet]; let text = "tauri::Builder::default().invoke_handler(tauri::generate_handler![greet])"; /* tauri::Builder::default().invoke_handler(tauri::generate_handler![greet]); */ }', 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' }); + expect(links(graph)).toEqual([]); + }); + it('isolates sibling apps and a nested app without registration', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/root.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function rootClick() { return invoke("greet"); }', 'apps/a/src-tauri/src/lib.rs': backend, 'apps/a/src/click.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function appClick() { return invoke("greet"); }', 'apps/b/src-tauri/src/lib.rs': '#[tauri::command]\nfn greet() {}', 'apps/b/src/click.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function unregisteredAppClick() { return invoke("greet"); }' }); + const rows = links(graph); expect(rows).toHaveLength(2); + const targetOf = new Map(graph.getNodesByName('greet').map(node => [node.id, node.filePath])); + for (const row of rows) expect(targetOf.get(row.edge.target)).toBe(row.node.name === 'appClick' ? 'apps/a/src-tauri/src/lib.rs' : 'src-tauri/src/lib.rs'); + }); + it('removes and restores unchanged callers when handler registration changes', async () => { + const { graph, root } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' }); + expect(links(graph)).toHaveLength(1); + write(root, 'src-tauri/src/lib.rs', backend.replace('generate_handler![greet, not_command]', 'generate_handler![not_command]')); await graph.sync(); + expect(links(graph)).toEqual([]); + write(root, 'src-tauri/src/lib.rs', backend); await graph.sync(); expect(links(graph)).toHaveLength(1); + write(root, 'src/a.ts', 'export function click() { return 0; }'); await graph.sync(); expect(links(graph)).toEqual([]); + }); + it('rejects duplicate external command keys from distinct registered modules', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': 'mod one; mod two; fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![one::greet, two::greet]); }', 'src-tauri/src/one.rs': '#[tauri::command]\nfn greet() {}', 'src-tauri/src/two.rs': '#[tauri::command]\nfn greet() {}', 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' }); + expect(links(graph)).toEqual([]); + }); + + it('keeps namespace and destructured parameter shadows unresolved', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import * as core from "@tauri-apps/api/core";\nfunction shadow(core: any) { return core.invoke("greet"); }', 'src/b.ts': 'import { invoke } from "@tauri-apps/api/core";\nfunction shadow({invoke}: {invoke: any}) { return invoke("greet"); }' }); + expect(links(graph)).toEqual([]); + }); + + it('attributes adjacent same-line calls to the correct callable', async () => { + const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nfunction first() { return invoke("greet"); } function second() { return 0; }' }); + expect(links(graph).map(row => row.node.name)).toEqual(['first']); + }); + + it('tracks aliased import and registration-only inputs for incremental synthesis', () => { + expect(hasSynthesisPattern('src/a.ts', 'import { invoke as callRust } from "@tauri-apps/api/core"; callRust("greet");')).toBe(true); + expect(hasSynthesisPattern('src-tauri/src/lib.rs', '#[tauri::command] fn greet() {}')).toBe(true); + }); +}); diff --git a/docs/tauri-ipc.md b/docs/tauri-ipc.md new file mode 100644 index 0000000000..c60d8f97c8 --- /dev/null +++ b/docs/tauri-ipc.md @@ -0,0 +1,11 @@ +# Tauri Command Links + +The Tauri synthesis pass links JavaScript/TypeScript calls imported from `@tauri-apps/api/core` or `@tauri-apps/api/tauri` to Rust commands. Named import aliases and namespace imports are supported. The first argument must be a static string literal. + +A destination requires both `#[tauri::command]` and registration in `tauri::generate_handler![...]` passed directly to an `invoke_handler` call whose receiver chain originates at `tauri::Builder::default()`. Rust macro token trees and frontend import/call nodes are parsed, so comments and string decoys do not establish registration. Standard module paths such as `commands::save` are mapped under `src-tauri/src`; the external command key remains `save`. + +Calls stay within the nearest conventional application root. Ambiguous keys, shadowed imports, dynamic keys, plugin commands, top-level calls without a callable owner, and unregistered/undecorated functions produce no edge. Edges use `provenance: heuristic`, `synthesizedBy: tauri-command`, invoke line/column and `registeredAt` evidence. Existing synthesis input tracking rebuilds the links when registration or invocation changes. + +This initial contribution does not model global `window.__TAURI__` access, wrapper/re-export functions, aliased Rust macros, builder variables, nonstandard crate/module paths or frontend Vue/Svelte scripts. Explicit registration is source evidence, not a claim that an application ran or granted command permissions. + +Fixture precision and sync behavior are tested. Required small/medium/large real-repository probes and agent A/B validation remain pending; framework support is not marked fully validated. diff --git a/src/graph/source-syntax.ts b/src/graph/source-syntax.ts new file mode 100644 index 0000000000..7c93461f7e --- /dev/null +++ b/src/graph/source-syntax.ts @@ -0,0 +1,46 @@ +import type { Node as TreeNode } from 'web-tree-sitter'; +import type { Language } from '../types'; +import { getParser } from '../extraction/grammars'; + +export type SyntaxNode = TreeNode; + +export function visitSyntax(node: SyntaxNode, visit: (node: SyntaxNode) => void): void { + const pending = [node]; + while (pending.length) { + const current = pending.pop()!; + visit(current); + for (let i = current.namedChildCount - 1; i >= 0; i--) pending.push(current.namedChild(i)!); + } +} + +/** The callback cannot retain nodes after the tree is released. */ +export function withSourceTree(source: string, language: Language, read: (root: SyntaxNode) => T): T | null { + const parser = getParser(language); + if (!parser) return null; + const tree = parser.parse(source); + if (!tree) return null; + try { return read(tree.rootNode); } + finally { tree.delete(); } +} + +export function literalValue(node: SyntaxNode | null): string | null { + if (!node || !['string', 'string_literal', 'interpreted_string_literal', 'raw_string_literal'].includes(node.type)) return null; + const text = node.text; + if (/^r#*"/.test(text)) return text.replace(/^r#*"/, '').replace(/"#*$/, ''); + if (text.startsWith('`') && text.endsWith('`')) return text.slice(1, -1); + if (text.startsWith('"')) { + try { return JSON.parse(text) as string; } catch { return null; } + } + if (text.startsWith("'") && text.endsWith("'") && !text.slice(1, -1).includes('\\')) return text.slice(1, -1); + return null; +} + +export function attributesBefore(node: SyntaxNode): string { + const attributes: string[] = []; + for (let previous = node.previousNamedSibling; previous; previous = previous.previousNamedSibling) { + if (previous.type.includes('comment')) continue; + if (previous.type !== 'attribute_item') break; + attributes.unshift(previous.text); + } + return attributes.join('\n'); +} diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ce41c3da94..036acd91af 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -28,6 +28,8 @@ import { isGeneratedFile } from '../extraction/generated-detection'; import { stripCommentsForRegex } from './strip-comments'; import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer'; import { goframeRouteEdges } from './goframe-synthesizer'; +import { tauriCommandEdges } from './tauri-synthesizer'; + import { expoRouterReturnEdges } from './expo-router-synthesizer'; import { nextLinkEdges } from './next-router-synthesizer'; import { reactRouterLinkEdges } from './react-router-synthesizer'; @@ -4384,6 +4386,9 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean /\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true; if (/\b(?:class|interface|protocol|trait|impl|extends|implements|expect|actual)\b/.test(content)) return true; if (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true; + if (/\.rs$/.test(filePath) && /tauri\s*::\s*(?:command|generate_handler)/.test(content)) return true; + if (/\.(?:[cm]?[jt]sx?)$/.test(filePath) && content.includes('@tauri-apps/api/')) return true; + if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true; if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true; if (/\.(?:forEach|append|add|push|insert|fire|dispatchEvent|addListener|Use|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\s*\(/.test(content)) return true; @@ -4419,6 +4424,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [ // a queue job onto its consumer, a bus / socket event onto its handler. // Before the in-process emitter pass: the same (source, target) pair // keeps the more specific edge — the one that says which tier it crosses. + { name: 'tauriEdges', gate: (has) => has('rust') && has(...JS_FAMILY), run: (_q, c, y) => tauriCommandEdges(c, y) }, + { name: 'tierEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => crossTierEdges(c, y) }, // A Spring / Laravel test's request (`mockMvc.perform(post("/x"))`, // `$this->postJson('api/x')`) onto the route it reaches, so the handler diff --git a/src/resolution/tauri-synthesizer.ts b/src/resolution/tauri-synthesizer.ts new file mode 100644 index 0000000000..dd0858bc6e --- /dev/null +++ b/src/resolution/tauri-synthesizer.ts @@ -0,0 +1,170 @@ +import type { Edge, Language, Node } from '../types'; +import type { ResolutionContext } from './types'; +import type { MaybeYield } from './cooperative-yield'; +import { loadGrammarsForLanguages } from '../extraction/grammars'; +import { attributesBefore, literalValue, visitSyntax, withSourceTree, type SyntaxNode } from '../graph/source-syntax'; +import { enclosingFn, FN_KINDS } from './synth-utils'; +import { jsCodeBindsName } from './name-matcher'; + +const JS_FILES = /\.(?:[cm]?[jt]sx?)$/; +const API_MODULES = new Set(['@tauri-apps/api/core', '@tauri-apps/api/tauri']); +interface Command { node: Node; qualified: string; } +interface Registration { file: string; line: number; qualified: string; } + +function appForRust(file: string): string | null { + const marker = file.indexOf('src-tauri/src/'); + return marker < 0 || (marker > 0 && file[marker - 1] !== '/') ? null : file.slice(0, marker).replace(/\/$/, ''); +} +function modulePrefix(file: string): string { + return file.slice(file.indexOf('src-tauri/src/') + 14).replace(/\.rs$/, '').replace(/(?:^|\/)(?:main|lib|mod)$/, '').split('/').filter(Boolean).join('::'); +} +function inlineModules(node: SyntaxNode): string[] { + const names: string[] = []; + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === 'mod_item') { const name = parent.childForFieldName('name')?.text; if (name) names.unshift(name); } + } + return names; +} +function qualifiedIn(file: string, node: SyntaxNode, name: string): string { + if (name.startsWith('crate::')) return name.slice(7); + return [modulePrefix(file), ...inlineModules(node), name].filter(Boolean).join('::'); +} + +function handlerCommands(macro: SyntaxNode): string[] { + const tokenTree = macro.namedChildren.find(child => child.type === 'token_tree'); + if (!tokenTree) return []; + const commands: string[] = []; let parts: string[] = []; + const flush = () => { const name = parts.join(''); if (/^(?:crate::)?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*$/.test(name)) commands.push(name); parts = []; }; + for (const token of tokenTree.children) { + if (['[', ']', '(', ')', '{', '}'].includes(token.text) || token.type.includes('comment')) continue; + if (token.text === ',') flush(); else parts.push(token.type === 'identifier' || token.text === 'crate' || token.text === '::' ? token.text : '?'); + } + flush(); return commands; +} +function isBuilderHandler(macro: SyntaxNode): boolean { + const args = macro.parent; const call = args?.parent; + if (args?.type !== 'arguments' || call?.type !== 'call_expression') return false; + const fn = call.childForFieldName('function'); + if (fn?.type !== 'field_expression' || fn.childForFieldName('field')?.text !== 'invoke_handler') return false; + const receiver = fn.childForFieldName('value'); let builder = false; + if (receiver) visitSyntax(receiver, node => { + if (node.type === 'call_expression' && /^tauri\s*::\s*Builder\s*::\s*default$/.test(node.childForFieldName('function')?.text ?? '')) builder = true; + }); + return builder; +} + +function patternBinds(pattern: SyntaxNode | null, name: string): boolean { + if (!pattern) return false; + if (['identifier', 'shorthand_property_identifier_pattern'].includes(pattern.type)) return pattern.text === name; + if (['required_parameter', 'optional_parameter'].includes(pattern.type)) return patternBinds(pattern.childForFieldName('pattern'), name); + if (pattern.type === 'assignment_pattern') return patternBinds(pattern.childForFieldName('left'), name); + if (pattern.type === 'pair_pattern') return patternBinds(pattern.childForFieldName('value'), name); + if (['formal_parameters', 'object_pattern', 'array_pattern', 'rest_pattern'].includes(pattern.type)) return pattern.namedChildren.some(child => patternBinds(child, name)); + return false; +} +function lexicalShadow(call: SyntaxNode, name: string): boolean { + for (let scope: SyntaxNode | null = call; scope; scope = scope.parent) { + if (patternBinds(scope.childForFieldName('parameters'), name) || patternBinds(scope.childForFieldName('parameter'), name)) return true; + if (!['program', 'statement_block', 'for_statement', 'for_in_statement'].includes(scope.type)) continue; + for (const entry of scope.namedChildren) { + const declaration = entry.type === 'export_statement' ? entry.childForFieldName('declaration') : entry; + if (!declaration) continue; + if (['function_declaration', 'class_declaration'].includes(declaration.type) && declaration.childForFieldName('name')?.text === name) return true; + if (['lexical_declaration', 'variable_declaration'].includes(declaration.type) && declaration.namedChildren.some(child => child.type === 'variable_declarator' && patternBinds(child.childForFieldName('name'), name))) return true; + } + } + return false; +} +function callOwner(nodes: readonly Node[], line: number, column: number): Node | null { + const eligible = nodes.filter(node => FN_KINDS.has(node.kind) && node.startLine <= line && node.endLine >= line + && (node.startLine !== line || node.startColumn <= column) && (node.endLine !== line || node.endColumn > column)) + .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn); + return enclosingFn(eligible, line); +} + +export async function tauriCommandEdges(context: ResolutionContext, onYield: MaybeYield): Promise { + const rustFiles = context.getAllFiles().filter(file => file.endsWith('.rs') && appForRust(file) !== null); + if (!rustFiles.length) return []; + await loadGrammarsForLanguages(['rust']); + const apps = new Map(); + for (const file of rustFiles) { + await onYield(); + const app = appForRust(file)!; + if (!apps.has(app)) apps.set(app, { commands: [], registrations: [] }); + const records = apps.get(app)!; const source = context.readFile(file); + if (!source || !/tauri\s*::\s*(?:command|generate_handler)/.test(source)) continue; + const nodes = context.getNodesInFile(file); + withSourceTree(source, 'rust', root => { + if (root.hasError) return; + visitSyntax(root, node => { + if (node.type === 'function_item' && /#\s*\[\s*tauri\s*::\s*command\b/.test(attributesBefore(node))) { + const name = node.childForFieldName('name')?.text; + const target = nodes.find(candidate => candidate.kind === 'function' && candidate.name === name && candidate.startLine === node.startPosition.row + 1); + if (target && name) records.commands.push({ node: target, qualified: qualifiedIn(file, node, name) }); + } + if (node.type === 'macro_invocation' && /^tauri\s*::\s*generate_handler$/.test(node.childForFieldName('macro')?.text ?? '') && isBuilderHandler(node)) { + for (const name of handlerCommands(node)) records.registrations.push({ file, line: node.startPosition.row + 1, qualified: qualifiedIn(file, node, name) }); + } + }); + }); + } + const registered = new Map>(); + for (const [app, records] of apps) { + const table = new Map(); + for (const registration of records.registrations) { + const candidates = records.commands.filter(command => command.qualified === registration.qualified); + if (candidates.length !== 1) continue; + const command = candidates[0]!; const name = command.node.name; + const entries = table.get(name) ?? []; + if (!entries.some(entry => entry.command.node.id === command.node.id)) entries.push({ command, registration }); + table.set(name, entries); + } + if (table.size) registered.set(app, table); + } + if (!registered.size) return []; + const roots = [...apps.keys()].sort((a, b) => b.length - a.length); + const edges: Edge[] = []; const seen = new Set(); + for (const file of context.getAllFiles()) { + if (!JS_FILES.test(file)) continue; + await onYield(); + const app = roots.find(root => !root || file.startsWith(root + '/')); + if (app === undefined) continue; + const source = context.readFile(file); + if (!source || !source.includes('@tauri-apps/api/')) continue; + const language: Language = file.endsWith('tsx') ? 'tsx' : file.endsWith('jsx') ? 'jsx' : /\.[cm]?js$/.test(file) ? 'javascript' : 'typescript'; + await loadGrammarsForLanguages([language]); + const nodes = context.getNodesInFile(file); + withSourceTree(source, language, root => { + if (root.hasError) return; + const direct = new Set(); const namespaces = new Set(); + for (const statement of root.namedChildren) { + if (statement.type !== 'import_statement' || !API_MODULES.has(literalValue(statement.childForFieldName('source')) ?? '')) continue; + if (statement.text.startsWith('import type ')) continue; + visitSyntax(statement, node => { + if (node.type === 'import_specifier' && node.childForFieldName('name')?.text === 'invoke' && !node.text.startsWith('type ')) direct.add(node.childForFieldName('alias')?.text ?? 'invoke'); + if (node.type === 'namespace_import') { const name = node.namedChildren[0]?.text; if (name) namespaces.add(name); } + }); + } + if (!direct.size && !namespaces.size) return; + visitSyntax(root, call => { + if (call.type !== 'call_expression') return; + const fn = call.childForFieldName('function'); let binding: string | undefined; + if (fn?.type === 'identifier' && direct.has(fn.text)) binding = fn.text; + if (fn?.type === 'member_expression' && fn.childForFieldName('property')?.text === 'invoke') { + const object = fn.childForFieldName('object'); if (object?.type === 'identifier' && namespaces.has(object.text)) binding = object.text; + } + if (!binding || lexicalShadow(call, binding)) return; + const key = literalValue(call.childForFieldName('arguments')?.namedChildren[0] ?? null); + if (!key || key.startsWith('plugin:')) return; + const targets = registered.get(app)?.get(key); if (targets?.length !== 1) return; + const line = call.startPosition.row + 1; const column = call.startPosition.column; + const owner = callOwner(nodes, line, column); + if (!owner || jsCodeBindsName(binding, owner, file, line, context)) return; + const target = targets[0]!; const edgeKey = owner.id + '>' + target.command.node.id; + if (seen.has(edgeKey)) return; seen.add(edgeKey); + edges.push({ source: owner.id, target: target.command.node.id, kind: 'calls', line, column, provenance: 'heuristic', metadata: { synthesizedBy: 'tauri-command', confidence: 0.95, command: key, registeredAt: target.registration.file + ':' + target.registration.line } }); + }); + }); + } + return edges; +}