From 9b5c8e49dd8f7dff659b9766fb0593952cb60350 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 27 Sep 2026 13:09:37 +0000 Subject: [PATCH] fix(extraction): recover single-argument C/C++ function macros (#1373) (#2017) Single-argument function macros were indexed under parentheses in C or the macro name in C++, breaking caller resolution. Recover both parser shapes only when a preceding local macro definition establishes the function name, and mirror the recovery in the Rust kernel. Preserve existing multi-argument recovery and ordinary declarations; add extraction, resolution, explore-flow, and parity regressions. Co-authored-by: Claude Opus 5.5 --- __tests__/extraction.test.ts | 46 +++++++++++++++ __tests__/kernel-ccpp-parity.test.ts | 27 +++++++++ __tests__/resolution.test.ts | 28 +++++++++ codegraph-kernel/src/ccpp/mod.rs | 87 ++++++++++++++++++++++++++++ src/extraction/languages/c-cpp.ts | 52 ++++++++++++++++- 5 files changed, 239 insertions(+), 1 deletion(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 1a971e1c5..b5a28a345 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -4001,6 +4001,52 @@ enum class EDenseMode : uint8 }); }); + describe('C/C++ single-argument function macros (#1373)', () => { + it.each(['c', 'cpp'] as const)('recovers single-argument function macros in %s (#1373)', (language) => { + const code = '#define NATIVE_FN(name) int name(void)\n' + + 'NATIVE_FN(get_version) { return helper(); }\n' + + 'int use_it(void) { return get_version(); }\n'; + const result = extractFromSource(`main.${language}`, code, language); + const functions = result.nodes.filter((n) => n.kind === 'function'); + expect(functions.map((n) => n.name)).toEqual(['get_version', 'use_it']); + expect(functions[0]).toMatchObject({ qualifiedName: 'get_version', startLine: 2, endLine: 2, startColumn: 0 }); + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ fromNodeId: functions[0].id, referenceName: 'helper', referenceKind: 'calls' }), + expect.objectContaining({ fromNodeId: functions[1].id, referenceName: 'get_version', referenceKind: 'calls' }), + ])); + }); + + it.each(['c', 'cpp'] as const)('does not guess single-argument macro names in %s (#1373)', (language) => { + for (const prefix of [ + '', + '// #define NATIVE_FN(name) int name(void)\n', + '#define NATIVE_FN(name) int fixed(name)\n', + '#define NATIVE_FN(name) int test_ ## name(void)\n', + '#define NATIVE_FN(name) register_test(name)\n', + '#define NATIVE_FN(name) typedef int name(void)\n', + '#define NATIVE_FN(name) int name(void)\n#define NATIVE_FN int\n', + '#define NATIVE_FN(name) int name(void)\n#ifdef OTHER\n#undef NATIVE_FN\n#endif\n', + '#define NATIVE_FN(name) int name(void)\n#undef NATIVE_FN\n', + '#define NATIVE_FN(name) int name(void)\n#define NATIVE_FN(name) int fixed(name)\n', + ]) { + const result = extractFromSource(`main.${language}`, prefix + 'NATIVE_FN(candidate) { return 1; }\n', language); + expect(result.nodes.filter((n) => n.kind === 'function').map((n) => n.name)).not.toContain('candidate'); + } + const alternate = extractFromSource(`main.${language}`, [ + '#ifdef OTHER', '#define NATIVE_FN(name) int name(void)', '#else', + 'NATIVE_FN(candidate) { return 1; }', '#endif', '', + ].join('\n'), language); + expect(alternate.nodes.filter((n) => n.kind === 'function').map((n) => n.name)).not.toContain('candidate'); + const ordinary = extractFromSource(`main.${language}`, 'int (parenthesized)(void) { return 1; }\n', language); + expect(ordinary.nodes.find((n) => n.kind === 'function')?.name).toBe('(parenthesized)'); + if (language === 'c') { + const knr = extractFromSource('knr.c', 'int old_style(arg) int arg; { return arg; }\n', 'c'); + expect(knr.nodes.find((n) => n.kind === 'function')?.name).toBe('old_style'); + } + }); + + }); + describe('CUDA extraction (#387)', () => { // CUDA parses with the C++ grammar. Three CUDA-only shapes misparse: // execution-space specifiers (`__global__ void f(…)`) shunt the real return diff --git a/__tests__/kernel-ccpp-parity.test.ts b/__tests__/kernel-ccpp-parity.test.ts index fce31f357..2908d4232 100644 --- a/__tests__/kernel-ccpp-parity.test.ts +++ b/__tests__/kernel-ccpp-parity.test.ts @@ -95,6 +95,33 @@ describe.skipIf(!kernelBuilt)('kernel C/C++ extraction parity', () => { expect(viaWasm.nodes.length).toBeGreaterThanOrEqual(minNodes); } + it.each(['c', 'cpp'] as const)('single-argument function macros and negative controls: %s (#1373)', (language) => { + const source = [ + '#define NATIVE_FN(name) int name(void)', + 'NATIVE_FN(get_version) { return helper(); }', + 'int use_it(void) { return get_version(); }', + '#define FN(name) int name(void)', + 'FN(short_macro) { return 0; }', + '#define POINTER_FN(name) const char *name(void)', + 'POINTER_FN(get_text) { return 0; }', + '#define TEST_CASE(name) int test_ ## name(void)', + 'TEST_CASE(candidate) { return 0; }', + '#define REGISTER_FN(name) register_test(name)', + 'REGISTER_FN(registration) { return 0; }', + 'int (parenthesized)(void) { return 1; }', + '', + ].join('\n'); + assertParity(`fixtures/macros.${language}`, source, language); + assertParity(`fixtures/macros-crlf.${language}`, source.replace(/\n/g, '\r\n'), language); + const result = tryKernelExtract(`fixtures/macros.${language}`, source, language)!; + const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(names).toContain('get_version'); + expect(names).toContain('get_text'); + expect(names).toContain('short_macro'); + expect(names).not.toContain('candidate'); + expect(names).not.toContain('registration'); + }); + it('torture fixture (c): fn-ptr tables, typedefs, file-scope consts, value-refs', () => { const file = path.join(FIXTURE_DIR, 'torture.c'); assertParity('fixtures/torture.c', fs.readFileSync(file, 'utf8'), 'c'); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 9c837a6a2..96d9fc932 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -39,6 +39,34 @@ describe('Resolution Module', () => { fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5 }); }); + it.each(['c', 'cpp'] as const)('connects single-argument function macros in %s (#1373)', async (language) => { + const file = `main.${language}`; + fs.writeFileSync(path.join(tempDir, file), [ + '#define NATIVE_FN(name) int name(void)', + 'int helper(void) { return 1; }', + 'NATIVE_FN(get_version) { return helper(); }', + 'int use_it(void) { return get_version(); }', + 'int plain_func(void) { return 42; }', + '', + ].join('\n')); + cg = await CodeGraph.init(tempDir, { index: true }); + const functions = cg.getNodesByKind('function'); + const recovered = functions.find((n) => n.name === 'get_version'); + expect(recovered).toBeDefined(); + expect(cg.getCallers(recovered!.id).map((c) => c.node.name)).toContain('use_it'); + expect(cg.getCallees(recovered!.id).map((c) => c.node.name)).toContain('helper'); + const caller = functions.find((n) => n.name === 'use_it')!; + expect(cg.getCallees(caller.id).map((c) => c.node.id)).toContain(recovered!.id); + expect(functions.map((n) => n.name)).toContain('plain_func'); + const { ToolHandler } = await import('../src/mcp/tools'); + const result = await new ToolHandler(cg).execute('codegraph_explore', { query: 'use_it get_version helper' }); + expect(result.isError).toBeUndefined(); + const text = result.content[0]!.text!; + expect(text).toContain('Flow'); + expect(text).toMatch(/use_it[^\n]*get_version[^\n]*helper/); + + }); + describe('Name Matcher', () => { it('should match exact name references', () => { // Create a mock context diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index e82715d76..197f163ca 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -93,6 +93,12 @@ fn has_lower_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| Regex::new(r"[a-z]").unwrap()) } +fn single_arg_macro_replacement_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new( + r"^(?:[A-Za-z_][A-Za-z0-9_:]*[ \t\r\n]+)+[*& \t\r\n]*([A-Za-z_][A-Za-z0-9_]*)[ \t\r\n]*\([^(){};#]*\)[ \t\r\n]*$" + ).unwrap()) +} /// normalizeCppReturnType: smart-pointer/optional unwrap. fn ret_wrapper_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); @@ -593,6 +599,9 @@ impl<'t> Walker<'t> { /// extractNameRaw for the c/cpp extractor configs (nameField 'declarator'; /// cpp resolveName = extractCppQualifiedMethodName). fn extract_name_raw(&self, node: Node) -> String { + if let Some(name) = self.recover_single_arg_macro_defined_name(node) { + return name; + } if self.variant == Variant::Cpp { if let Some(hook) = self.extract_cpp_qualified_method_name(node) { return hook; @@ -650,6 +659,84 @@ impl<'t> Walker<'t> { parts.last().map(|s| s.to_string()) } + /// recoverSingleArgMacroDefinedName: only a local macro definition proves + /// that its argument is the function name, in either parser shape (#1373). + fn recover_single_arg_macro_defined_name(&self, node: Node) -> Option { + if node.kind() != "function_definition" { + return None; + } + let declarator = node.child_by_field_name("declarator")?; + let (macro_node, argument) = if declarator.kind() == "parenthesized_declarator" + && declarator.named_child_count() == 1 + { + let m = node.child_by_field_name("type")?; + let a = declarator.named_child(0)?; + if m.kind() != "type_identifier" || a.kind() != "identifier" { + return None; + } + (m, a) + } else if declarator.kind() == "function_declarator" + && node.child_by_field_name("type").is_none() + { + let m = declarator.child_by_field_name("declarator")?; + let params = declarator.child_by_field_name("parameters")?; + let param = params.named_child(0)?; + if m.kind() != "identifier" || params.named_child_count() != 1 + || param.kind() != "parameter_declaration" || param.named_child_count() != 1 + { + return None; + } + let a = param.named_child(0)?; + if a.kind() != "type_identifier" { + return None; + } + (m, a) + } else { + return None; + }; + let macro_name = self.text(macro_node); + let mut scope = Some(node); + while let Some(current) = scope { + if current.kind() == "preproc_else" || current.kind().starts_with("preproc_elif") { + return None; + } + let mut previous = current.prev_named_sibling(); + while let Some(prev) = previous { + previous = prev.prev_named_sibling(); + if prev.kind().starts_with("preproc_if") { + return None; + } + if prev.kind() == "preproc_call" + && prev.child_by_field_name("directive").map(|n| self.text(n)) == Some("#undef") + && prev.child_by_field_name("argument").map(|n| self.text(n).trim()) == Some(macro_name) + { + return None; + } + if !matches!(prev.kind(), "preproc_function_def" | "preproc_def") + || prev.child_by_field_name("name").map(|n| self.text(n)) != Some(macro_name) + { + continue; + } + let params = prev.child_by_field_name("parameters")?; + let param = params.named_child(0)?; + let value = prev.child_by_field_name("value")?; + if params.named_child_count() != 1 || param.kind() != "identifier" { + return None; + } + let replacement = self.text(value).replace("\\\r\n", " ").replace("\\\n", " "); + let captures = single_arg_macro_replacement_re().captures(replacement.trim())?; + if replacement.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').any(|s| s == "typedef") + || captures.get(1)?.as_str() != self.text(param) + { + return None; + } + return Some(self.text(argument).to_string()); + } + scope = current.parent(); + } + None + } + /// recoverCppMacroDefinedName (languages/c-cpp.ts:49). fn recover_cpp_macro_defined_name(&self, node: Node) -> Option { if node.kind() != "function_definition" { diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index afce3a014..88141abad 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -27,6 +27,55 @@ function findDeclaratorQualifiedId(declarator: SyntaxNode): SyntaxNode | undefin return undefined; } +/** + * Single-argument macros are ambiguous without their definition (#1373). + * Require a preceding local #define whose replacement uses its sole parameter + * as the function declarator; registration, token-pasting, and K&R forms stay + * untouched. Walk enclosing scopes, but never guess across conditional macros. + */ +function recoverSingleArgMacroDefinedName(node: SyntaxNode, source: string): string | undefined { + if (node.type !== 'function_definition') return undefined; + const declarator = getChildByField(node, 'declarator'); + let macro: SyntaxNode | null = null; + let argument: SyntaxNode | null = null; + if (declarator?.type === 'parenthesized_declarator' && declarator.namedChildCount === 1) { + // C: NATIVE_FN is parsed as the return type, (name) as the declarator. + macro = getChildByField(node, 'type'); + argument = declarator.namedChild(0); + if (macro?.type !== 'type_identifier' || argument?.type !== 'identifier') return undefined; + } else if (declarator?.type === 'function_declarator' && !getChildByField(node, 'type')) { + // C++: NATIVE_FN(name) is parsed as an implicit-return-type function. + macro = getChildByField(declarator, 'declarator'); + const params = getChildByField(declarator, 'parameters'); + const param = params?.namedChild(0); + if (macro?.type !== 'identifier' || params?.namedChildCount !== 1 || + param?.type !== 'parameter_declaration' || param.namedChildCount !== 1) return undefined; + argument = param.namedChild(0); + if (argument?.type !== 'type_identifier') return undefined; + } + if (!macro || !argument) return undefined; + const macroName = getNodeText(macro, source); + for (let scope: SyntaxNode | null = node; scope; scope = scope.parent) { + if (scope.type === 'preproc_else' || scope.type.startsWith('preproc_elif')) return undefined; + for (let prev = scope.previousNamedSibling; prev; prev = prev.previousNamedSibling) { + if (prev.type.startsWith('preproc_if')) return undefined; + if (prev.type === 'preproc_call' && getChildByField(prev, 'directive')?.text === '#undef' && + getChildByField(prev, 'argument')?.text.trim() === macroName) return undefined; + if (prev.type !== 'preproc_function_def' && prev.type !== 'preproc_def') continue; + if (getChildByField(prev, 'name')?.text !== macroName) continue; + const params = getChildByField(prev, 'parameters'); + const param = params?.namedChild(0); + const value = getChildByField(prev, 'value'); + if (params?.namedChildCount !== 1 || param?.type !== 'identifier' || !value) return undefined; + const replacement = getNodeText(value, source).replace(/\\\r?\n/g, ' ').trim(); + const match = replacement.match(/^(?:[A-Za-z_][A-Za-z0-9_:]*\s+)+[*&\s]*([A-Za-z_]\w*)\s*\([^(){};#]*\)\s*$/); + if (!match || /\btypedef\b/.test(replacement) || match[1] !== param.text) return undefined; + return getNodeText(argument, source); + } + } + return undefined; +} + /** * Recover the real function name from the macro-definition idiom * `MACRO_NAME(real_name, typed args…) { body }` — flash-attention's @@ -73,7 +122,7 @@ function recoverCppMacroDefinedName(node: SyntaxNode, source: string): string | } function extractCppQualifiedMethodName(node: SyntaxNode, source: string): string | undefined { - const macroDefined = recoverCppMacroDefinedName(node, source); + const macroDefined = recoverSingleArgMacroDefinedName(node, source) ?? recoverCppMacroDefinedName(node, source); if (macroDefined) return macroDefined; const declarator = getChildByField(node, 'declarator'); if (!declarator) return undefined; @@ -207,6 +256,7 @@ function extractCppReturnType(node: SyntaxNode, source: string): string | undefi } export const cExtractor: LanguageExtractor = { + resolveName: recoverSingleArgMacroDefinedName, // CUDA in C-detected headers (content-gated blank; see preParseCSource). preParse: preParseCSource, // Universal net: recover a real name from any macro-mangled function name.