fix: resolve cross-directory C/C++ quoted includes (#4354)

(cherry picked from commit 97b8c8dbf2)
This commit is contained in:
himanshupatro-334 authored and safishamsi committed 2026-10-12 23:02:50 +01:00
1 parent 2531ab7dfc
commit a4028f3c8d
3 files changed
+693 -4

No files matched your search

+161 -2
View File
@@ -1112,7 +1112,7 @@ def _import_c(node, source: bytes, file_nid: str, stem: str, edges: list, str_pa
module_name = raw.split("/")[-1].split(".")[0]
if module_name:
tgt_nid = _make_id(module_name)
edges.append({
edge_dict = {
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
@@ -1121,7 +1121,13 @@ def _import_c(node, source: bytes, file_nid: str, stem: str, edges: list, str_pa
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
}
if child.type != "system_lib_string":
edge_dict["metadata"] = sanitize_metadata({
"raw_include": raw,
"include_kind": "quoted",
})
edges.append(edge_dict)
break
@@ -6289,6 +6295,150 @@ def _resolve_kotlin_import_targets(
e["target"] = candidates[0]
_C_FAMILY_HEADER_EXTS = frozenset({".h", ".hpp", ".hh", ".hxx", ".cuh", ".inl"})
def _resolve_c_include_edges(
edges: list[dict],
resolution_nodes: list[dict],
root: Path | None = None,
) -> None:
"""Resolve unresolved quoted C/C++/ObjC include edges to matching header file nodes (#4354).
When a quoted #include / #import is outside the including file's directory,
_resolve_c_include_path cannot resolve it, so per-file extraction emits a
fallback edge targeting a bare stem ID with metadata={"raw_include": ..., "include_kind": "quoted"}.
This pass runs after canonical node IDs and collision disambiguation are
established, but before import-evidence indexing.
Resolution rules:
1. Only processes unresolved quoted includes (metadata.include_kind == "quoted"
and metadata.raw_include is present).
2. Explicit relative (./ or ../) or absolute paths that failed sibling
resolution are never resolved via corpus fallback.
3. Matches the full include path as a path suffix, not merely the basename
when the include contains directory components.
4. Prefers exact-case matches over case-insensitive matches.
5. If no exact-case candidate exists, falls back to case-insensitive matching.
6. If multiple candidates remain, uses longest-shared-directory-prefix as a
tie-breaker. Resolves only when one candidate has a strictly greater score
than all others; ties remain unresolved. This is a heuristic and does not
claim to reproduce compiler include-order semantics.
7. Resolved edges point to the canonical target file node ID.
8. Unresolved edges remain untouched for external stub creation.
"""
if not edges or not resolution_nodes:
return
headers_by_basename: dict[str, list[tuple[dict, str]]] = {}
headers_by_basename_ci: dict[str, list[tuple[dict, str]]] = {}
for n in resolution_nodes:
if n.get("type") == "namespace" or n.get("file_type") == "rationale":
continue
sf = n.get("source_file")
if not sf:
continue
sf_str = str(sf)
sf_p = Path(sf_str)
if sf_p.suffix.lower() not in _C_FAMILY_HEADER_EXTS:
continue
if n.get("label") != sf_p.name:
continue
if not n.get("id"):
continue
sf_key = _source_key(sf_str, root) if root else sf_str
cand_path = sf_key.replace("\\", "/").rstrip("/")
basename = cand_path.split("/")[-1]
headers_by_basename.setdefault(basename, []).append((n, cand_path))
headers_by_basename_ci.setdefault(basename.lower(), []).append((n, cand_path))
if not headers_by_basename:
return
def _shared_dir_prefix_len(p1_parts: list[str], p2_parts: list[str]) -> int:
score = 0
for a, b in zip(p1_parts, p2_parts):
if a == b:
score += 1
else:
break
return score
for e in edges:
if e.get("relation") != "imports":
continue
md = e.get("metadata")
if not isinstance(md, dict):
continue
if md.get("include_kind") != "quoted":
continue
raw = md.get("raw_include")
if not raw or not isinstance(raw, str):
continue
raw_norm = raw.replace("\\", "/").strip()
if not raw_norm:
continue
if raw_norm.startswith(("./", "../", "/")):
continue
try:
if Path(raw).is_absolute():
continue
except Exception:
pass
target_base = raw_norm.split("/")[-1]
if not target_base:
continue
exact_cands = headers_by_basename.get(target_base, [])
matches = [
(node, cand_path) for node, cand_path in exact_cands
if cand_path == raw_norm or cand_path.endswith("/" + raw_norm)
]
if not matches:
ci_cands = headers_by_basename_ci.get(target_base.lower(), [])
raw_norm_lower = raw_norm.lower()
matches = [
(node, cand_path) for node, cand_path in ci_cands
if cand_path.lower() == raw_norm_lower or cand_path.lower().endswith("/" + raw_norm_lower)
]
if not matches:
continue
winner_node: dict | None = None
if len(matches) == 1:
winner_node = matches[0][0]
else:
imp_sf = str(e.get("source_file", ""))
imp_key = _source_key(imp_sf, root) if root else imp_sf
imp_path = imp_key.replace("\\", "/").rstrip("/")
imp_parts = imp_path.split("/")[:-1]
scored: list[tuple[int, dict]] = []
for node, cand_path in matches:
cand_parts = cand_path.split("/")[:-1]
score = _shared_dir_prefix_len(imp_parts, cand_parts)
scored.append((score, node))
max_score = max(s[0] for s in scored)
top_matches = [node for score, node in scored if score == max_score]
if len(top_matches) == 1:
winner_node = top_matches[0]
else:
winner_node = None
if winner_node is not None:
e["target"] = winner_node["id"]
e.pop("target_file", None)
def _resolve_csharp_qualified_calls(
per_file: list[dict],
all_nodes: list[dict],
@@ -9705,6 +9855,15 @@ def extract(
resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER],
)
# C/C++/ObjC quoted include resolution (#4354): resolve quoted include edges
# outside the including file's directory to canonical header file nodes.
# Runs after node-ID disambiguation and before import-evidence indexing.
try:
_resolve_c_include_edges(all_edges, resolution_nodes, root)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("C include resolution failed, skipping: %s", exc)
# Build evidence index from import edges so cross-file calls backed by an
# explicit import statement can be promoted from INFERRED to EXTRACTED.
# Direct symbol imports (`import { foo }` / `const { foo } = require()`) are
+16 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from graphify.extractors.base import _file_stem, _make_id, _read_source_bytes, _read_text
from graphify.extractors.engine import _cpp_declarator_name, _semantic_reference_edge
from graphify.extractors.resolution import _resolve_c_include_path
from graphify.security import sanitize_metadata
from pathlib import Path
from typing import Any
import re
@@ -123,12 +124,15 @@ def extract_objc(path: Path) -> dict:
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
context: str | None = None,
metadata: dict | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
if metadata:
edge["metadata"] = metadata
edges.append(edge)
file_nid = _make_id(str(path))
@@ -279,7 +283,17 @@ def extract_objc(path: Path) -> dict:
else:
module = raw.split("/")[-1].replace(".h", "")
if module:
add_edge(file_nid, _make_id(module), "imports", line, context="import")
add_edge(
file_nid,
_make_id(module),
"imports",
line,
context="import",
metadata=sanitize_metadata({
"raw_include": raw,
"include_kind": "quoted",
}),
)
return
if t == "module_import":
+516
View File
@@ -0,0 +1,516 @@
"""Focused regression tests for C/C++/ObjC quoted include resolution (#4354).
Covers:
1. Core/Src/adc.c resolving "adc.h" to Core/Inc/adc.h.
2. Nested harness resolving headers from another directory.
3. Directory-qualified include (e.g., "sub/util.h").
4. Unique longest-shared-directory-prefix winner.
5. Equally scored candidates remaining unresolved (tie).
6. Exact-case matches taking precedence over case-insensitive alternatives.
7. Case-insensitive fallback when no exact match exists.
8. Failed relative paths (./ or ../) remaining unresolved.
9. Angle-bracket system includes not binding to local same-named files.
10. Existing sibling include behavior remaining unchanged.
11. Correct target IDs after .h/.c collision disambiguation.
12. Cache consistency and incremental extraction after a header is added or removed.
13. Objective-C parity for quoted #import / #include outside sibling directory.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import networkx as nx
from graphify.extract import extract
from graphify.build import build
def _real(tmp_path: Path) -> Path:
return Path(os.path.realpath(tmp_path))
def test_stm32_core_src_resolves_to_core_inc(tmp_path: Path) -> None:
"""Core/Src/adc.c resolving "adc.h" to Core/Inc/adc.h."""
root = _real(tmp_path)
src_dir = root / "Core" / "Src"
inc_dir = root / "Core" / "Inc"
src_dir.mkdir(parents=True, exist_ok=True)
inc_dir.mkdir(parents=True, exist_ok=True)
adc_h = inc_dir / "adc.h"
adc_h.write_text("void adc_init(void);\n", encoding="utf-8")
adc_c = src_dir / "adc.c"
adc_c.write_text('#include "adc.h"\nvoid adc_init(void) {}\n', encoding="utf-8")
result = extract([adc_c, adc_h], root=root, cache_root=root)
assert not result.get("error")
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "adc.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
edge = import_edges[0]
header_nodes = [
n for n in result["nodes"]
if "adc.h" in str(n.get("source_file", "")) and n.get("label") == "adc.h"
]
assert len(header_nodes) == 1
assert edge["target"] == header_nodes[0]["id"]
assert "target_file" not in edge
# Verify build graph produces 0 external stub nodes for adc.h
G = build([result], directed=True, root=root)
stubs = [
n for n, d in G.nodes(data=True)
if d.get("external") and d.get("label") == "adc"
]
assert not stubs, f"Expected no stub node for adc, found {stubs}"
assert G.has_edge(edge["source"], edge["target"])
def test_nested_harness_resolves_headers_from_another_dir(tmp_path: Path) -> None:
"""A nested test harness resolving headers from another directory."""
root = _real(tmp_path)
tests_dir = root / "tests" / "harness"
app_dir = root / "app"
tests_dir.mkdir(parents=True, exist_ok=True)
app_dir.mkdir(parents=True, exist_ok=True)
nfc_h = app_dir / "nfc.h"
nfc_h.write_text("void nfc_poll(void);\n", encoding="utf-8")
harness_c = tests_dir / "harness.c"
harness_c.write_text('#include "nfc.h"\nint main(void) { return 0; }\n', encoding="utf-8")
result = extract([harness_c, nfc_h], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "harness.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
header_nodes = [
n for n in result["nodes"]
if "nfc.h" in str(n.get("source_file", "")) and n.get("label") == "nfc.h"
]
assert len(header_nodes) == 1
assert import_edges[0]["target"] == header_nodes[0]["id"]
def test_directory_qualified_include(tmp_path: Path) -> None:
"""Directory-qualified include such as "sub/util.h"."""
root = _real(tmp_path)
src_dir = root / "src"
inc_sub_dir = root / "include" / "sub"
other_dir = root / "other"
src_dir.mkdir(parents=True, exist_ok=True)
inc_sub_dir.mkdir(parents=True, exist_ok=True)
other_dir.mkdir(parents=True, exist_ok=True)
util_matching = inc_sub_dir / "util.h"
util_matching.write_text("int sub_util(void);\n", encoding="utf-8")
util_unrelated = other_dir / "util.h"
util_unrelated.write_text("int other_util(void);\n", encoding="utf-8")
main_c = src_dir / "main.c"
main_c.write_text('#include "sub/util.h"\n', encoding="utf-8")
result = extract([main_c, util_matching, util_unrelated], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
matching_header_node = next(
n for n in result["nodes"]
if "include" in str(n.get("source_file", "")) and n.get("label") == "util.h"
)
assert import_edges[0]["target"] == matching_header_node["id"]
def test_unique_longest_prefix_winner(tmp_path: Path) -> None:
"""Unique longest-shared-directory-prefix winner resolves."""
root = _real(tmp_path)
drv_src = root / "components" / "driver" / "src"
drv_inc = root / "components" / "driver" / "include"
sen_inc = root / "components" / "sensor" / "include"
drv_src.mkdir(parents=True, exist_ok=True)
drv_inc.mkdir(parents=True, exist_ok=True)
sen_inc.mkdir(parents=True, exist_ok=True)
drv_h = drv_inc / "adc.h"
drv_h.write_text("int driver_adc(void);\n", encoding="utf-8")
sen_h = sen_inc / "adc.h"
sen_h.write_text("int sensor_adc(void);\n", encoding="utf-8")
importer = drv_src / "adc.c"
importer.write_text('#include "adc.h"\n', encoding="utf-8")
result = extract([importer, drv_h, sen_h], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "adc.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
drv_header_node = next(
n for n in result["nodes"]
if "driver" in str(n.get("source_file", "")) and n.get("label") == "adc.h"
)
assert import_edges[0]["target"] == drv_header_node["id"]
def test_equally_scored_candidates_remain_unresolved(tmp_path: Path) -> None:
"""Equally scored candidates remain unresolved (tie fails closed)."""
root = _real(tmp_path)
test_dir = root / "test"
mod_a = root / "module_a"
mod_b = root / "module_b"
test_dir.mkdir(parents=True, exist_ok=True)
mod_a.mkdir(parents=True, exist_ok=True)
mod_b.mkdir(parents=True, exist_ok=True)
util_a = mod_a / "util.h"
util_a.write_text("int util_a(void);\n", encoding="utf-8")
util_b = mod_b / "util.h"
util_b.write_text("int util_b(void);\n", encoding="utf-8")
harness = test_dir / "harness.c"
harness.write_text('#include "util.h"\n', encoding="utf-8")
result = extract([harness, util_a, util_b], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "harness.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
edge = import_edges[0]
# Target remains the fallback stem
assert edge["target"] == "util"
# Build creates an external stub node because of the tie
G = build([result], directed=True, root=root)
stub_data = G.nodes.get("util")
assert stub_data is not None
assert stub_data.get("external") is True
def test_exact_case_precedence_over_case_insensitive(tmp_path: Path) -> None:
"""Exact-case match takes precedence over case-insensitive alternatives."""
root = _real(tmp_path)
src_dir = root / "src"
inc1_dir = root / "inc1"
inc2_dir = root / "inc2"
src_dir.mkdir(parents=True, exist_ok=True)
inc1_dir.mkdir(parents=True, exist_ok=True)
inc2_dir.mkdir(parents=True, exist_ok=True)
exact_h = inc1_dir / "adc.h"
exact_h.write_text("int exact_adc(void);\n", encoding="utf-8")
upper_h = inc2_dir / "ADC.h"
upper_h.write_text("int upper_adc(void);\n", encoding="utf-8")
main_c = src_dir / "main.c"
main_c.write_text('#include "adc.h"\n', encoding="utf-8")
result = extract([main_c, exact_h, upper_h], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
exact_node = next(
n for n in result["nodes"]
if "inc1" in str(n.get("source_file", "")) and n.get("label") == "adc.h"
)
assert import_edges[0]["target"] == exact_node["id"]
def test_case_insensitive_fallback_when_no_exact_match(tmp_path: Path) -> None:
"""Case-insensitive fallback succeeds when no exact match exists."""
root = _real(tmp_path)
src_dir = root / "src"
inc_dir = root / "inc"
src_dir.mkdir(parents=True, exist_ok=True)
inc_dir.mkdir(parents=True, exist_ok=True)
upper_h = inc_dir / "ADC.h"
upper_h.write_text("int upper_adc(void);\n", encoding="utf-8")
main_c = src_dir / "main.c"
main_c.write_text('#include "adc.h"\n', encoding="utf-8")
result = extract([main_c, upper_h], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
upper_node = next(
n for n in result["nodes"]
if "inc" in str(n.get("source_file", "")) and n.get("label") == "ADC.h"
)
assert import_edges[0]["target"] == upper_node["id"]
def test_failed_relative_path_remains_unresolved(tmp_path: Path) -> None:
"""Failed relative paths (./ or ../) do not bind to unrelated corpus files."""
root = _real(tmp_path)
src_dir = root / "src"
other_dir = root / "other"
src_dir.mkdir(parents=True, exist_ok=True)
other_dir.mkdir(parents=True, exist_ok=True)
unrelated_h1 = other_dir / "missing.h"
unrelated_h1.write_text("int missing1(void);\n", encoding="utf-8")
unrelated_h2 = other_dir / "missing_parent.h"
unrelated_h2.write_text("int missing2(void);\n", encoding="utf-8")
other2_dir = root / "other2"
other2_dir.mkdir(parents=True, exist_ok=True)
unrelated_h1b = other2_dir / "missing.h"
unrelated_h1b.write_text("int missing1b(void);\n", encoding="utf-8")
unrelated_h2b = other2_dir / "missing_parent.h"
unrelated_h2b.write_text("int missing2b(void);\n", encoding="utf-8")
main_c = src_dir / "main.c"
main_c.write_text(
'#include "./missing.h"\n'
'#include "../missing_parent.h"\n'
'int main(void) { return 0; }\n',
encoding="utf-8",
)
result = extract(
[main_c, unrelated_h1, unrelated_h2, unrelated_h1b, unrelated_h2b],
root=root,
cache_root=root,
)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 2
other_node_ids = {
n["id"] for n in result["nodes"]
if "other" in str(n.get("source_file", ""))
}
# Neither edge should resolve to any of the "other" files
for edge in import_edges:
assert edge["target"] not in other_node_ids
assert edge["target"] in ("missing", "missing_parent")
G = build([result], directed=True, root=root)
for stub_id in ("missing", "missing_parent"):
data = G.nodes.get(stub_id)
assert data is not None
assert data.get("external") is True
def test_angle_bracket_system_includes_not_bound_locally(tmp_path: Path) -> None:
"""Angle-bracket system includes do not bind to local corpus files."""
root = _real(tmp_path)
src_dir = root / "src"
inc1_dir = root / "include1"
inc2_dir = root / "include2"
src_dir.mkdir(parents=True, exist_ok=True)
inc1_dir.mkdir(parents=True, exist_ok=True)
inc2_dir.mkdir(parents=True, exist_ok=True)
local_stdio1 = inc1_dir / "stdio.h"
local_stdio1.write_text("// local fake stdio 1\n", encoding="utf-8")
local_stdio2 = inc2_dir / "stdio.h"
local_stdio2.write_text("// local fake stdio 2\n", encoding="utf-8")
main_c = src_dir / "main.c"
main_c.write_text('#include <stdio.h>\nint main(void) { return 0; }\n', encoding="utf-8")
result = extract([main_c, local_stdio1, local_stdio2], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
edge = import_edges[0]
local_stdio_ids = {
n["id"] for n in result["nodes"]
if "stdio.h" in str(n.get("source_file", ""))
}
assert edge["target"] not in local_stdio_ids
assert edge["target"] == "stdio"
G = build([result], directed=True, root=root)
stub_data = G.nodes.get("stdio")
assert stub_data is not None
assert stub_data.get("external") is True
def test_existing_sibling_include_behavior_unchanged(tmp_path: Path) -> None:
"""Existing sibling include resolution remains prioritized and unchanged."""
root = _real(tmp_path)
src_dir = root / "src"
other_dir = root / "other"
src_dir.mkdir(parents=True, exist_ok=True)
other_dir.mkdir(parents=True, exist_ok=True)
sibling_h = src_dir / "helper.h"
sibling_h.write_text("int sibling_helper(void);\n", encoding="utf-8")
other_h = other_dir / "helper.h"
other_h.write_text("int other_helper(void);\n", encoding="utf-8")
main_c = src_dir / "main.c"
main_c.write_text('#include "helper.h"\n', encoding="utf-8")
result = extract([main_c, sibling_h, other_h], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
]
assert len(import_edges) == 1
sibling_node = next(
n for n in result["nodes"]
if "src" in str(n.get("source_file", "")) and n.get("label") == "helper.h"
)
assert import_edges[0]["target"] == sibling_node["id"]
def test_collision_disambiguated_header_and_c_source(tmp_path: Path) -> None:
"""Header and C source with identical stem resolve to the header, not source."""
root = _real(tmp_path)
app_dir = root / "app"
test_dir = root / "test"
app_dir.mkdir(parents=True, exist_ok=True)
test_dir.mkdir(parents=True, exist_ok=True)
nfc_h = app_dir / "nfc.h"
nfc_h.write_text("void nfc_run(void);\n", encoding="utf-8")
nfc_c = app_dir / "nfc.c"
nfc_c.write_text('#include "nfc.h"\nvoid nfc_run(void) {}\n', encoding="utf-8")
main_c = test_dir / "main.c"
main_c.write_text('#include "nfc.h"\n', encoding="utf-8")
result = extract([main_c, nfc_h, nfc_c], root=root, cache_root=root)
header_node = next(
n for n in result["nodes"]
if "nfc.h" in str(n.get("source_file", "")) and n.get("label") == "nfc.h"
)
source_node = next(
n for n in result["nodes"]
if "nfc.c" in str(n.get("source_file", "")) and n.get("label") == "nfc.c"
)
# Disambiguation must separate .h and .c
assert header_node["id"] != source_node["id"]
import_edge = next(
e for e in result["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
)
assert import_edge["target"] == header_node["id"]
def test_cache_consistency_when_header_added_and_removed(tmp_path: Path) -> None:
"""Cache consistency: resolution dynamically reacts when header is added or removed."""
root = _real(tmp_path)
cache_dir = root / ".cache"
src_dir = root / "src"
inc_dir = root / "inc"
src_dir.mkdir(parents=True, exist_ok=True)
inc_dir.mkdir(parents=True, exist_ok=True)
main_c = src_dir / "main.c"
main_c.write_text('#include "util.h"\n', encoding="utf-8")
# Pass 1: No util.h in project
r1 = extract([main_c], root=root, cache_root=cache_dir)
edge1 = next(e for e in r1["edges"] if e.get("relation") == "imports")
assert edge1["target"] == "util"
# Pass 2: Add inc/util.h. Even with main.c cached, whole-graph pass resolves it!
util_h = inc_dir / "util.h"
util_h.write_text("int util_func(void);\n", encoding="utf-8")
r2 = extract([main_c, util_h], root=root, cache_root=cache_dir)
edge2 = next(
e for e in r2["edges"]
if e.get("relation") == "imports" and "main.c" in str(e.get("source_file", ""))
)
header_node = next(
n for n in r2["nodes"]
if "util.h" in str(n.get("source_file", "")) and n.get("label") == "util.h"
)
assert edge2["target"] == header_node["id"]
# Pass 3: Remove inc/util.h. It should revert to unresolved stem
util_h.unlink()
r3 = extract([main_c], root=root, cache_root=cache_dir)
edge3 = next(e for e in r3["edges"] if e.get("relation") == "imports")
assert edge3["target"] == "util"
def test_objc_quoted_include_and_import_parity(tmp_path: Path) -> None:
"""Objective-C parity: quoted #import / #include outside sibling resolves to header."""
root = _real(tmp_path)
src_dir = root / "App"
inc_dir = root / "Shared" / "Headers"
src_dir.mkdir(parents=True, exist_ok=True)
inc_dir.mkdir(parents=True, exist_ok=True)
helper_h = inc_dir / "Helper.h"
helper_h.write_text(
"@interface Helper : NSObject\n"
"- (void)help;\n"
"@end\n",
encoding="utf-8",
)
app_m = src_dir / "AppDelegate.m"
app_m.write_text(
'#import "Helper.h"\n'
'#import <Foundation/Foundation.h>\n'
'@implementation AppDelegate\n'
'@end\n',
encoding="utf-8",
)
result = extract([app_m, helper_h], root=root, cache_root=root)
import_edges = [
e for e in result["edges"]
if e.get("relation") == "imports" and "AppDelegate.m" in str(e.get("source_file", ""))
]
assert len(import_edges) == 2
helper_node = next(
n for n in result["nodes"]
if "Helper.h" in str(n.get("source_file", "")) and n.get("label") == "Helper.h"
)
helper_edge = next(e for e in import_edges if e["target"] == helper_node["id"])
assert helper_edge is not None
foundation_edge = next(e for e in import_edges if e["target"] == "foundation")
assert foundation_edge is not None