mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
fix: resolve cross-directory C/C++ quoted includes (#4354)
(cherry picked from commit 97b8c8dbf2)
This commit is contained in:
1 parent
2531ab7dfc
commit
a4028f3c8d
3 files changed
+693
-4
No files matched your search
+161
-2
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user