/graphify-out/graph.json)")
diff --git a/graphify/affected.py b/graphify/affected.py
index 0184a8f88f..3b8118e638 100644
--- a/graphify/affected.py
+++ b/graphify/affected.py
@@ -1,13 +1,15 @@
from __future__ import annotations
-from collections import deque
+from collections import defaultdict, deque
from dataclasses import dataclass
from pathlib import Path
-from typing import Iterable
+from typing import Iterable, cast
import unicodedata
import networkx as nx
+from graphify.graph_direction import logical_endpoints
+
DEFAULT_AFFECTED_RELATIONS = (
"calls",
@@ -206,13 +208,28 @@ def affected_nodes(
# otherwise. The member nodes are seeds only (not reported as hits), and
# `method`/`contains` stay out of the general relation-filtered walk, so this
# adds no forward noise anywhere else.
+ directed = graph.is_directed()
+ incoming_by_target = defaultdict(list)
if hasattr(graph, "out_edges"):
- member_edges = graph.out_edges(seed, data=True)
+ member_edges = cast(nx.DiGraph, graph).out_edges(seed, data=True)
else:
- member_edges = (
- (s, t, d) for s, t, d in graph.edges(data=True) if s == seed
- )
+ # Index accepted logical pairs once. Undirected adjacency iteration
+ # cannot recover direction; rescanning every edge per BFS node is also
+ # unnecessary. Directed graphs retain their efficient native indexes.
+ member_edges = []
+ for source, target, data in graph.edges(data=True):
+ endpoints = logical_endpoints(source, target, data, directed=False)
+ if endpoints is None:
+ continue
+ source, target = endpoints
+ incoming_by_target[target].append((source, target, data))
+ if source == seed:
+ member_edges.append((source, target, data))
for _s, member, data in member_edges:
+ endpoints = logical_endpoints(_s, member, data, directed=directed)
+ if endpoints is None or endpoints[0] != seed:
+ continue
+ _s, member = endpoints
if str(data.get("relation", "")) not in ("method", "contains"):
continue
member = str(member)
@@ -225,14 +242,14 @@ def affected_nodes(
if current_depth >= depth:
continue
if hasattr(graph, "in_edges"):
- incoming = graph.in_edges(current, data=True)
+ incoming = cast(nx.DiGraph, graph).in_edges(current, data=True)
else:
- incoming = (
- (source, target, data)
- for source, target, data in graph.edges(data=True)
- if target == current
- )
+ incoming = incoming_by_target.get(current, ())
for source, _target, data in incoming:
+ endpoints = logical_endpoints(source, _target, data, directed=directed)
+ if endpoints is None or endpoints[1] != current:
+ continue
+ source, _target = endpoints
relation = str(data.get("relation", ""))
if relation not in relation_set:
continue
@@ -251,6 +268,17 @@ def affected_nodes(
)
hits.append(hit)
queue.append((source, current_depth + 1))
+ # Versioned source occurrences carry the dependency; their actual
+ # extracted owners are affected at the same dependency distance.
+ from graphify.qt_affected import owned_ancestors
+ for owner, ownership in owned_ancestors(graph, source):
+ if owner in seen:
+ continue
+ seen.add(owner)
+ hits.append(AffectedHit(owner, current_depth + 1, relation,
+ via_file=str(data.get("source_file") or "") or None,
+ via_location=str(data.get("source_location") or "") or None))
+ queue.append((owner, current_depth + 1))
return hits
diff --git a/graphify/build.py b/graphify/build.py
index 871b927987..3a125f9384 100644
--- a/graphify/build.py
+++ b/graphify/build.py
@@ -148,6 +148,7 @@ def mint_external_stubs_in_data(data: dict) -> None:
# edge (#1547/#1556) is dropped. Kept local to build.py (not imported from extract.py,
# which imports build.py — a cycle) and deliberately mirrors extract._LANG_FAMILY_BY_EXT.
_EDGE_LANG_FAMILY: dict[str, str] = {
+ ".qml": "qml",
".py": "py", ".pyi": "py",
".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "js",
".ts": "js", ".tsx": "js", ".mts": "js", ".cts": "js",
@@ -1383,8 +1384,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# bind to a same-named node in another language. The extraction spec forbids
# this for `calls`; it is equally invalid for `imports`/`references` (a
# Python `import time` must not bind to a `time.ts`, #1749).
+ # Typed Qt/QML bridges also use `uses` for property access, emissions and
+ # loading. Validate their source mechanism before the generic relation
+ # gate so changing a relation cannot bypass endpoint/source authority.
+ from graphify.qt_qml_projection import allows_qt_qml_edge, is_typed_qt_qml_edge
+ if is_typed_qt_qml_edge(G.nodes[src], G.nodes[tgt], attrs) and not allows_qt_qml_edge(
+ G.nodes[src], G.nodes[tgt], attrs, source_id=src, target_id=tgt, nodes=G.nodes):
+ continue
_edge_rel = attrs.get("relation")
if _edge_rel in ("calls", "imports", "imports_from", "references"):
+ from graphify.qml_projection import allows_qml_script_edge
+ qml_script_edge = allows_qml_script_edge(G.nodes[src], G.nodes[tgt], attrs, target_id=tgt)
+ qt_qml_edge = allows_qt_qml_edge(G.nodes[src], G.nodes[tgt], attrs,
+ source_id=src, target_id=tgt, nodes=G.nodes)
src_ext = Path(G.nodes[src].get("source_file") or "").suffix.lower()
tgt_ext = Path(G.nodes[tgt].get("source_file") or "").suffix.lower()
src_fam = _EDGE_LANG_FAMILY.get(src_ext)
@@ -1395,13 +1407,14 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
if (
attrs.get("confidence") == "INFERRED"
and src_ext and tgt_ext and src_fam != tgt_fam
+ and not (qml_script_edge or qt_qml_edge)
):
continue
else:
# imports/references: drop only when BOTH endpoints are known code
# languages of different families, so a config->code reference
# (unknown ext, e.g. a manifest) is never mistaken for a phantom.
- if src_fam is not None and tgt_fam is not None and src_fam != tgt_fam:
+ if src_fam is not None and tgt_fam is not None and src_fam != tgt_fam and not (qml_script_edge or qt_qml_edge):
continue
# A file-level import or re-export cannot carry useful connectivity when
# both endpoints resolve to the same node. This most often happens when
diff --git a/graphify/cache.py b/graphify/cache.py
index f9031f7597..060aa90f98 100644
--- a/graphify/cache.py
+++ b/graphify/cache.py
@@ -36,7 +36,10 @@
_EXTRACTOR_VERSION = "unknown"
# Bump when AST cache-key semantics change independently of the package version.
-_AST_CACHE_SCHEMA = 5 # Python receiver-shadow facts in persisted raw calls.
+# The integrated producer carries both Qt API/callback/reference-callable facts
+# and upstream Python receiver-shadow facts. Neither schema-12 Qt entries nor
+# schema-5 upstream entries establish that combined transport contract.
+_AST_CACHE_SCHEMA = 13
# Version dirs already swept this process — cleanup runs once per (base, version).
_cleaned_ast_dirs: set[str] = set()
diff --git a/graphify/callflow_html.py b/graphify/callflow_html.py
index 3c8a34b41f..e0c73c11b0 100644
--- a/graphify/callflow_html.py
+++ b/graphify/callflow_html.py
@@ -572,7 +572,8 @@ def preferred_edges(edges: list, allow_structure: bool = False) -> list:
if not should_include_edge(edge):
continue
relation = edge.get("relation", "")
- if relation in primary or (allow_structure and relation in secondary):
+ from graphify.qt_relationship_views import relationship_label
+ if relation in primary or relationship_label(edge) or (allow_structure and relation in secondary):
selected.append(edge)
if selected:
return selected
@@ -1245,6 +1246,9 @@ def generate_call_table_rows(
callers = defaultdict(set)
callees = defaultdict(set)
for e in (all_edges if all_edges is not None else section_edges):
+ from graphify.qt_relationship_views import is_event_relationship
+ if is_event_relationship(e):
+ continue
src = e.get("source", "")
tgt = e.get("target", "")
if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method", "indirect_call"):
@@ -1778,6 +1782,9 @@ def write_callflow_html(
""")
+ from graphify.qt_relationship_views import render_relationship_table
+ html.append(render_relationship_table(nodes, edges, lang))
+
# ── Section: Hyperedges (if any) ──
if hyperedges:
html.append("""Group Relationships (Hyperedges)
diff --git a/graphify/cli.py b/graphify/cli.py
index 5008b89c50..a57acc6356 100644
--- a/graphify/cli.py
+++ b/graphify/cli.py
@@ -1679,6 +1679,8 @@ def dispatch_command(cmd: str) -> None:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
+ # Resolve exact IDs and qualified source::symbol names before scoring;
+ # the selected graph still retains Qt logical direction for traversal.
src_nid, src_scored, src_err = _resolve_path_endpoint(G, source_label)
tgt_nid, tgt_scored, tgt_err = _resolve_path_endpoint(G, target_label)
for _label, _nid, _err in (
@@ -1760,6 +1762,7 @@ def dispatch_command(cmd: str) -> None:
sys.exit(0)
hops = len(path_nodes) - 1
segments = []
+ path_edges = []
from graphify.build import edge_datas
for i in range(len(path_nodes) - 1):
u, v = path_nodes[i], path_nodes[i + 1]
@@ -1779,6 +1782,7 @@ def dispatch_command(cmd: str) -> None:
for d in edge_datas(G, a, b):
(fwd if d.get("_src", a) == u else bwd).append(d)
datas = fwd or bwd
+ path_edges.extend(datas)
forward = bool(fwd)
rels = sorted({d.get("relation") for d in datas if d.get("relation")})
rel = "/".join(rels) if rels else "related"
@@ -1791,6 +1795,10 @@ def dispatch_command(cmd: str) -> None:
else:
segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}")
print(f"Shortest path ({hops} hops):\n " + " ".join(segments))
+ from graphify.path_provenance import path_provenance
+ evidence = path_provenance([G.nodes[nid] for nid in path_nodes], path_edges)
+ if evidence:
+ print(evidence.lstrip("\n"))
from graphify import querylog
querylog.log_query(
kind="path",
@@ -2488,6 +2496,7 @@ def _clear_html_stale_marker() -> None:
elif cmd == "update":
force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes")
no_cluster = False
+ follow_symlinks = False
args = sys.argv[2:]
watch_arg: str | None = None
for a in args:
@@ -2497,6 +2506,9 @@ def _clear_html_stale_marker() -> None:
if a == "--no-cluster":
no_cluster = True
continue
+ if a == "--follow-symlinks":
+ follow_symlinks = True
+ continue
if a.startswith("-"):
print(f"error: unknown update option: {a}", file=sys.stderr)
sys.exit(2)
@@ -2527,7 +2539,10 @@ def _clear_html_stale_marker() -> None:
# Interactive CLI: block on the per-repo lock rather than skip, so the
# user sees their explicit `graphify update` complete instead of
# exiting silently when a hook-driven rebuild happens to be running.
- ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, block_on_lock=True)
+ # Discovery remains opt-in; the existing watch owner checks containment
+ # and publishes the same accepted graph/state cohort for either profile.
+ ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster,
+ follow_symlinks=follow_symlinks, block_on_lock=True)
if ok:
print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
if not (
@@ -3732,6 +3747,26 @@ def _parse_float(name: str, raw: str) -> float:
existing_graph_path, target, _seen_files, detection=detection
)
+ # QML scope resolution needs unchanged component/metadata facts too.
+ # Until dependency-directed invalidation is implemented, rebuild the
+ # live code corpus on QML (including deletion) or QML-JS input changes.
+ from graphify.qml_safety import (
+ QmlSafetyError, qml_refresh_required, require_complete_qml,
+ )
+ from graphify.qt_analysis_state import inspect_qt_analysis, commit_qt_analysis
+ try:
+ qt_state = inspect_qt_analysis(target, graphify_out, files_by_type.get("code", []),
+ excludes=_effective_excludes or (), gitignore=_effective_gitignore)
+ except ValueError as exc:
+ print(str(exc), file=sys.stderr)
+ sys.exit(1)
+ if incremental_mode and ((qt_state.has_qt and qt_state.changed) or qml_refresh_required(
+ files_by_type.get("code", []),
+ [*code_files, *deleted_files, *excluded_files, *graph_stale_sources],
+ )):
+ code_files = [Path(p) for p in files_by_type.get("code", [])]
+ print("[graphify extract] Qt/QML inputs changed; refreshing the live code corpus.")
+
semantic_files = doc_files + paper_files + image_files
# --code-only: index code (pure local AST, no key) and skip the semantic
# (doc/paper/image) pass entirely, so a mixed repo doesn't hard-fail when no
@@ -3934,7 +3969,7 @@ def _parse_float(name: str, raw: str) -> float:
# graphify-out/ dir into a project that asked for external output.
# `root` stays the scanned project so source_file/ids relativize
# against it; conflating the two basenamed every node (#1941).
- ast_kwargs: dict = {"cache_root": out_root, "root": target}
+ ast_kwargs: dict = {"cache_root": out_root, "root": target, "qml_import_roots": qt_state.import_roots, "refresh_native": qt_state.has_qt}
if cli_max_workers is not None:
ast_kwargs["max_workers"] = cli_max_workers
# #2437/#2438 (the `graphify update` twin of watch's #2406 fix): an
@@ -4088,6 +4123,14 @@ def _ctx_identity(source_file) -> str | None:
sys.exit(1)
ast_result = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0}
_extraction_incomplete = True # the whole AST pass was lost
+ # An incomplete QML scope cannot replace a prior contribution even
+ # when total node counts increase or a force/partial flag is used.
+ # Reject before semantic work, graph reconciliation, or publication.
+ try:
+ require_complete_qml(ast_result, code_files, operation="extract", root=target)
+ except QmlSafetyError as exc:
+ print(f"[graphify extract] error: {exc}", file=sys.stderr)
+ sys.exit(1)
stages.mark("AST extract")
# Semantic extraction on docs/papers/images. Check cache first.
@@ -4451,6 +4494,8 @@ def _invalidate_file_manifest_for_db_graph() -> None:
)
try:
_save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target, scan_corpus=_scan_corpus, clear_semantic=_cleared_semantic, clear_ast=_cleared_ast or None)
+ if not _cleared_ast:
+ commit_qt_analysis(graphify_out, qt_state)
except Exception as exc:
print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
stages.total()
@@ -4574,6 +4619,8 @@ def _invalidate_file_manifest_for_db_graph() -> None:
try:
if has_path:
_save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target, scan_corpus=_scan_corpus, clear_semantic=_cleared_semantic, clear_ast=_cleared_ast or None)
+ if not _cleared_ast:
+ commit_qt_analysis(graphify_out, qt_state)
except Exception as exc:
print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
if global_merge:
@@ -4786,6 +4833,8 @@ def _invalidate_file_manifest_for_db_graph() -> None:
try:
if has_path:
_save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target, scan_corpus=_scan_corpus, clear_semantic=_cleared_semantic, clear_ast=_cleared_ast or None)
+ if not _cleared_ast:
+ commit_qt_analysis(graphify_out, qt_state)
except Exception as exc:
print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
diff --git a/graphify/cpp_header.py b/graphify/cpp_header.py
new file mode 100644
index 0000000000..1b18e9b2b4
--- /dev/null
+++ b/graphify/cpp_header.py
@@ -0,0 +1,69 @@
+"""Bounded lexical C++ header admission without importing optional parser bindings."""
+from __future__ import annotations
+
+from pathlib import Path
+import re
+
+_DECLARATION = re.compile(
+ rb"\b(?:class|namespace)\s+[A-Za-z_]\w*|\btemplate\s*<|::|"
+ rb"\b(?:public|private|protected)\s*:|\b(?:struct|union)\s+[A-Za-z_]\w*\s+final\b"
+)
+_NUMBER = re.compile(rb"(?:[0-9]|\.[0-9])(?:[A-Za-z0-9_.]|'[A-Za-z0-9_])*")
+_RAW = re.compile(rb'(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(')
+
+
+def _code(source: bytes) -> bytes:
+ """Hide inert tokens while preserving separation, continuation and literal bounds."""
+ result, offset = bytearray(source), 0
+ while offset < len(source):
+ number = _NUMBER.match(source, offset)
+ if number:
+ offset = number.end()
+ continue
+ end = offset
+ if source[offset:offset + 2] == b"/*":
+ close = source.find(b"*/", offset + 2)
+ end = len(source) if close < 0 else close + 2
+ elif source[offset:offset + 2] == b"//":
+ end = offset + 2
+ while end < len(source):
+ if (source[end:end + 1] == b"\n"
+ and source[end - 1:end] != b"\\" and source[end - 2:end] != b"\\\r"):
+ break
+ end += 1
+ else:
+ raw = _RAW.match(source, offset)
+ if raw:
+ close = source.find(b")" + raw[1] + b'"', raw.end())
+ end = len(source) if close < 0 else close + len(raw[1]) + 2
+ elif source[offset:offset + 1] in {b'"', b"'"}:
+ quote, end = source[offset], offset + 1
+ while end < len(source):
+ if source[end] == 92:
+ end += 2
+ elif source[end] == quote:
+ end += 1
+ break
+ else:
+ end += 1
+ if end > offset:
+ end = min(end, len(source))
+ result[offset:end] = b" " * (end - offset)
+ offset = end
+ else:
+ offset += 1
+ return bytes(result)
+
+
+def is_cpp_header(path: Path) -> bool:
+ """Recognize supported visible declarations; uncertain headers keep C dispatch.
+
+ This admission hint supplies no type/member or SDK authority. Objective-C
+ selection remains owned by the facade and precedes this C++ check.
+ """
+ try:
+ with path.open("rb") as stream:
+ source = stream.read(256 * 1024)
+ except OSError:
+ return False
+ return _DECLARATION.search(_code(source)) is not None
diff --git a/graphify/detect.py b/graphify/detect.py
index 52927f9f9c..8286369a79 100644
--- a/graphify/detect.py
+++ b/graphify/detect.py
@@ -46,6 +46,8 @@ class FileType(str, Enum):
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.vb', '.cbl', '.cob', '.cobol', '.cpy', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.vh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource', '.sol', '.erl', '.hrl', '.escript'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
+# Qt metadata is source input for bounded deterministic readers, not semantic prose.
+CODE_EXTENSIONS.update({'.qml', '.qmltypes', '.cmake', '.pro', '.pri', '.qrc'})
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
OFFICE_EXTENSIONS = {'.docx', '.xlsx'}
@@ -520,6 +522,8 @@ def classify_file(path: Path) -> FileType | None:
# document path — otherwise apm.yml (a .yml "document") would be LLM-extracted
# and a package would split into duplicate file-anchored nodes (#1377).
from graphify.manifest_ingest import is_package_manifest_path
+ if path.name in {"qmldir", "CMakeLists.txt"}:
+ return FileType.CODE
if is_package_manifest_path(path):
return FileType.CODE
# Compound extensions must be checked before simple suffix lookup
diff --git a/graphify/export.py b/graphify/export.py
index c1abfcf938..0be3a75118 100644
--- a/graphify/export.py
+++ b/graphify/export.py
@@ -370,19 +370,24 @@ def _json_sort_key(item: dict) -> str:
if isinstance(link, dict)
and link.get("source") in node_ids and link.get("target") in node_ids
]
+ # Validate preserved parallel links through the same Qt direction boundary.
+ from graphify.qt_export import logical_endpoints
for link in data["links"]:
if "confidence_score" not in link:
conf = link.get("confidence", "EXTRACTED")
link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0)
- # Restore original edge direction. Undirected NetworkX storage may
- # canonicalize endpoint order, flipping `calls` and other directional
- # edges in graph.json. The build path stashes the true endpoints in
- # _src/_tgt for exactly this purpose (#563).
- true_src = link.pop("_src", None)
- true_tgt = link.pop("_tgt", None)
- if true_src is not None and true_tgt is not None:
- link["source"] = true_src
- link["target"] = true_tgt
+ # Undirected storage may reverse accepted endpoints (#563). Validate
+ # the complete marker pair before consuming it; partial/foreign markers
+ # cannot become new serialized endpoints or disappear into legacy data.
+ try:
+ source, target = logical_endpoints(link["source"], link["target"], link, directed=G.is_directed())
+ except ValueError:
+ print("[graphify] error: QT_EXPORT_DIRECTION: invalid logical edge pair; JSON was not written. "
+ "Re-extract or repair the graph and retry.", file=sys.stderr)
+ return False
+ link.pop("_src", None)
+ link.pop("_tgt", None)
+ link["source"], link["target"] = source, target
# Canonicalize the key order WITHIN each node/link dict. node_link_data always
# appends the node key (`id`) at the end, so a node whose `id` was an inline
# attribute on a cold build (position varies) lands last after a read-rebuild
@@ -506,6 +511,13 @@ def _cypher_label(raw: str, fallback: str) -> str:
def to_cypher(G: nx.Graph, output_path: str) -> None:
+ # Qt/QML facts retain occurrence identities, logical direction and complete
+ # source contracts. Ordinary graphs continue through the unchanged writer.
+ from graphify.qt_export import qt_cypher_lines
+ qt_lines = qt_cypher_lines(G)
+ if qt_lines is not None:
+ write_text_atomic(output_path, "\n".join(qt_lines) + "\n")
+ return
lines = ["// Neo4j Cypher import - generated by /graphify", ""]
for node_id, data in G.nodes(data=True):
label = _cypher_escape(data.get("label", node_id))
diff --git a/graphify/exporters/graphdb.py b/graphify/exporters/graphdb.py
index d0bcc4f61b..ad2370e69e 100644
--- a/graphify/exporters/graphdb.py
+++ b/graphify/exporters/graphdb.py
@@ -4,6 +4,7 @@
from graphify.analyze import _node_community_map
import networkx as nx
import re
+from graphify.qt_export import edge_properties, export_properties, has_qt_qml, logical_endpoints, preflight_qt_payload
def _distinct_node_labels(G: nx.Graph) -> list[str]:
@@ -41,6 +42,9 @@ def push_to_neo4j(
) from e
node_community = _node_community_map(communities) if communities else {}
+ qt_payload = has_qt_qml(G)
+ if qt_payload:
+ preflight_qt_payload(G)
def _safe_rel(relation: str) -> str:
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
@@ -66,10 +70,7 @@ def _safe_label(label: str) -> str:
pass
for node_id, data in G.nodes(data=True):
- props = {
- k: v for k, v in data.items()
- if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
- }
+ props = export_properties(data, preserve_metadata=qt_payload)
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
@@ -84,16 +85,17 @@ def _safe_label(label: str) -> str:
for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
- props = {
- k: v for k, v in data.items()
- if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
- }
+ src, tgt = logical_endpoints(u, v, data) if qt_payload else (u, v)
+ props = edge_properties(src, tgt, data, preserve_metadata=qt_payload)
+ identity = " {graphify_fact_key: $fact_key}" if qt_payload else ""
+ key_args = {"fact_key": props["graphify_fact_key"]} if qt_payload else {}
session.run(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
- f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
- src=u,
- tgt=v,
+ f"MERGE (a)-[r:{rel}{identity}]->(b) SET r += $props",
+ src=src,
+ tgt=tgt,
props=props,
+ **key_args,
)
edges_pushed += 1
@@ -138,6 +140,9 @@ def push_to_falkordb(
from urllib.parse import urlparse
node_community = _node_community_map(communities) if communities else {}
+ qt_payload = has_qt_qml(G)
+ if qt_payload:
+ preflight_qt_payload(G)
def _safe_rel(relation: str) -> str:
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
@@ -179,10 +184,7 @@ def _safe_label(label: str) -> str:
pass
for node_id, data in G.nodes(data=True):
- props = {
- k: v for k, v in data.items()
- if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
- }
+ props = export_properties(data, preserve_metadata=qt_payload)
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
@@ -196,14 +198,14 @@ def _safe_label(label: str) -> str:
for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
- props = {
- k: v for k, v in data.items()
- if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
- }
+ src, tgt = logical_endpoints(u, v, data) if qt_payload else (u, v)
+ props = edge_properties(src, tgt, data, preserve_metadata=qt_payload)
+ identity = " {graphify_fact_key: $fact_key}" if qt_payload else ""
+ key_args = {"fact_key": props["graphify_fact_key"]} if qt_payload else {}
graph.query(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
- f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
- {"src": u, "tgt": v, "props": props},
+ f"MERGE (a)-[r:{rel}{identity}]->(b) SET r += $props",
+ {"src": src, "tgt": tgt, "props": props, **key_args},
)
edges_pushed += 1
diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index 14f7812499..573e93239e 100644
--- a/graphify/exporters/html.py
+++ b/graphify/exporters/html.py
@@ -9,6 +9,8 @@
import json
import networkx as nx
from graphify.security import sanitize_label
+from graphify.qt_html import edge_fields, edge_title, semantic_fields, semantic_title
+from graphify.qt_export import has_qt_qml
MAX_NODES_FOR_VIZ = 5_000
@@ -149,6 +151,7 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
// Build vis datasets
const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({{
+ ...n,
id: n.id, label: n.label, color: n.color, size: n.size,
font: n.font, title: n.title,
// Fermat/golden-angle spiral seed positions (#3699): spreading nodes out
@@ -160,9 +163,12 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
community: n.community, community_name: n.community_name,
source_file: n.source_file, file_type: n.file_type, degree: n.degree,
member_count: n.member_count,
+ qt_qml: n.qt_qml,
+ source_location: n.source_location,
}})));
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
+ ...e,
id: i, from: e.from, to: e.to,
label: '',
title: e.title,
@@ -219,8 +225,25 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
html += `Community: ${{esc(n.community_name || '')}}
`;
if (!isMetaNode) {{
html += `Source: ${{esc(n.source_file || '-')}}
`;
+ if (n.qt_qml && typeof n.qt_qml === 'object' && !Array.isArray(n.qt_qml)) {{
+ // Only the bounded public projection is displayed; opaque transport and
+ // provider data remain in RAW_NODES for faithful source-fact retention.
+ html += `Location: ${{esc(n.source_location || '-')}}
`;
+ Object.entries(n.qt_qml).forEach(([contract, fields]) => {{
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return;
+ Object.entries(fields).forEach(([key, value]) => {{
+ html += `${{esc(contract.toUpperCase())}} ${{esc(key.replace(/_/g, ' '))}}: ${{esc(Array.isArray(value) ? value.join(', ') : value)}}
`;
+ }});
+ }});
+ }}
}} else if (n.member_count !== undefined && n.member_count !== null) {{
html += `Members: ${{esc(n.member_count)}}
`;
+ // Membership links need source-edge counts; aggregate degree alone cannot
+ // describe internal relationships. Caller-supplied counts may be unknown.
+ const internalKnown = Number.isInteger(n.internal_source_edges);
+ const externalKnown = Number.isInteger(n.external_source_edges);
+ html += `Internal source edges: ${{esc(internalKnown ? n.internal_source_edges : 'unavailable')}}
`;
+ html += `External source edges: ${{esc(externalKnown ? n.external_source_edges : 'unavailable')}}
`;
}}
html += `Degree: ${{n.degree}}
`;
if (neighborIds.length) {{
@@ -278,7 +301,8 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
const q = searchInput.value.toLowerCase().trim();
searchResults.innerHTML = '';
if (!q) {{ searchResults.style.display = 'none'; return; }}
- const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
+ const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q) ||
+ JSON.stringify(n.qt_qml || '').toLowerCase().includes(q)).slice(0, 20);
if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
searchResults.style.display = 'block';
matches.forEach(n => {{
@@ -443,11 +467,23 @@ def to_html(
meta = _nx.Graph()
for cid, members in communities.items():
meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}"))
- edge_counts = _Counter()
+ edge_counts, internal_counts, external_counts = _Counter(), _Counter(), _Counter()
for u, v in G.edges():
cu, cv = node_to_community.get(u), node_to_community.get(v)
- if cu is not None and cv is not None and cu != cv:
+ if cu is None or cv is None:
+ continue
+ # Each source edge counts once, including parallel/directed
+ # edges and loops. Boundary edges belong to both communities;
+ # internal membership never invents an aggregate self-link.
+ if cu == cv:
+ internal_counts[cu] += 1
+ else:
+ external_counts[cu] += 1
+ external_counts[cv] += 1
edge_counts[(min(cu, cv), max(cu, cv))] += 1
+ for cid in communities:
+ meta.nodes[str(cid)].update(internal_source_edges=internal_counts[cid],
+ external_source_edges=external_counts[cid])
for (cu, cv), w in edge_counts.items():
meta.add_edge(str(cu), str(cv), weight=w,
relation=f"{w} cross-community edges", confidence="AGGREGATED")
@@ -480,6 +516,10 @@ def to_html(
"nodes": comm_ids,
})
meta.graph["hyperedges"] = remapped
+ # Community aggregation drops occurrence-level evidence. State that
+ # omission in the actual artifact instead of implying full Qt detail.
+ if has_qt_qml(G):
+ meta.graph["qt_qml_source_details_omitted"] = True
written = to_html(meta, meta_communities, output_path,
community_labels=community_labels, member_counts=mc)
if not written:
@@ -552,6 +592,16 @@ def to_html(
}
if member_counts:
node["member_count"] = member_counts.get(cid, len(communities.get(cid, [])))
+ if not data.get("file_type") and not data.get("source_file"):
+ # Only nonnegative integer source counts may become inspector
+ # facts; booleans, malformed strings and missing data stay unknown.
+ for field in ("internal_source_edges", "external_source_edges"):
+ value = data.get(field)
+ if type(value) is int and value >= 0:
+ node[field] = value
+ # Project bounded human-readable Qt/QML fields while retaining the
+ # complete source contract and user attributes, without editing G.
+ node.update(semantic_fields(data))
# Conditional learning fields — only present for annotated nodes, so
# un-annotated output keeps the exact pre-feature node dict shape.
entry = learning_overlay.get(str(node_id)) if learning_overlay else None
@@ -582,6 +632,7 @@ def to_html(
if stale:
lesson += " [code changed — re-verify]"
node["title"] = f"{label}\n{sanitize_label(lesson)}"
+ node["title"] += semantic_title(node)
vis_nodes.append(node)
# Build edges list. Restore original edge direction from _src/_tgt
@@ -598,11 +649,12 @@ def to_html(
"from": true_src,
"to": true_tgt,
"label": relation,
- "title": sanitize_label(f"{relation} [{confidence}]"),
+ "title": sanitize_label(f"{relation} [{confidence}]") + edge_title(data),
"dashes": confidence != "EXTRACTED",
"width": 2 if confidence == "EXTRACTED" else 1,
"color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35},
"confidence": confidence,
+ **edge_fields(data),
})
# Build community legend data
@@ -623,6 +675,8 @@ def _js_safe(obj) -> str:
hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
title = _html.escape(sanitize_label(_html_document_title(output_path)))
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
+ if G.graph.get("qt_qml_source_details_omitted"):
+ stats += "
Qt/QML source details are omitted in this community view."
html = f"""
diff --git a/graphify/extract.py b/graphify/extract.py
index b463ae3887..a498b4e916 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -15,6 +15,7 @@
from typing import Any, Callable
from .cache import load_cached, save_cached
+from .source_identity import normalize_input_source, resolved_source_owners, walked_relative_source
from .mcp_ingest import extract_mcp_config, is_mcp_config_path
from .manifest_ingest import extract_package_manifest, is_package_manifest_path
from .resolver_registry import (
@@ -57,6 +58,14 @@
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
from graphify.extractors.r import extract_r, resolve_r_sourced_calls # noqa: F401
+from graphify.extractors.qml import extract_qml # noqa: F401
+from graphify.extractors.qml_metadata import extract_qmldir # noqa: F401
+from graphify.extractors.qml_cmake import extract_cmake # noqa: F401
+from graphify.extractors.qml_qmake import extract_qmake # noqa: F401
+from graphify.extractors.qml_resources import extract_qrc # noqa: F401
+from graphify.extractors.qml_types import extract_qmltypes # noqa: F401
+from graphify.qml_safety import is_qml_path
+from graphify.qt_incremental import qt_syntax_cache_bypass, requires_native_refresh
from graphify.extractors.razor import extract_razor # noqa: F401
from graphify.extractors.robot import extract_robot # noqa: F401
from graphify.extractors.rust import extract_rust # noqa: F401
@@ -189,7 +198,8 @@ def _safe_extract(
extractor: Callable, path: Path, *, scan_root: Path | None = None
) -> dict:
try:
- if extractor is extract_python:
+ if extractor in (extract_python, extract_qml, extract_qmldir,
+ extract_cmake, extract_qmake, extract_qrc, extract_qmltypes):
return extractor(path, root=scan_root)
return extractor(path)
except RecursionError:
@@ -2974,9 +2984,10 @@ def extract_cpp(path: Path) -> dict:
except OSError:
# Let _extract_generic report the read failure in its usual shape.
return _augment_cpp_string_tests(path, _extract_generic(path, _CPP_CONFIG))
- result = _extract_generic(
- path, _CPP_CONFIG, source_override=_normalize_cpp_cli(source) or source
- )
+ from graphify.extractors.qt_cpp_syntax import normalize_qt_cpp
+ normalized = _normalize_cpp_cli(source) or source
+ normalized = normalize_qt_cpp(normalized) or normalized
+ result = _extract_generic(path, _CPP_CONFIG, source_override=normalized)
return _augment_cpp_string_tests(path, result)
@@ -3134,6 +3145,10 @@ def _canonicalize_csharp_namespace_nodes(all_nodes: list[dict], all_edges: list[
for node in all_nodes:
if node.get("type") != "namespace":
continue
+ # Qt/QML namespace-shaped facts represent distinct source declarations
+ # and occurrences. Equal labels cannot merge providers or resource aliases.
+ if node.get("metadata", {}).get("qml", {}).get("contract_version") == 1:
+ continue
label = node.get("label")
if isinstance(label, str):
by_label.setdefault(label, []).append(node)
@@ -3200,6 +3215,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool:
# Extensions absent from this map (docs, configs, unknown languages) resolve to
# no family and are never filtered — same permissive default as before.
_LANG_FAMILY_BY_EXT: dict[str, str] = {
+ ".qml": "qml",
# JS/TS module graph (SFCs embed JS/TS)
".js": "jsts", ".jsx": "jsts", ".mjs": "jsts", ".cjs": "jsts",
".ts": "jsts", ".tsx": "jsts", ".mts": "jsts", ".cts": "jsts",
@@ -6840,6 +6856,12 @@ def add_existing_edge(edge: dict) -> None:
_DISPATCH: dict[str, Any] = {
+ ".qml": extract_qml,
+ ".qmltypes": extract_qmltypes,
+ ".cmake": extract_cmake,
+ ".pro": extract_qmake,
+ ".pri": extract_qmake,
+ ".qrc": extract_qrc,
".py": extract_python,
".js": extract_js,
".jsx": extract_js,
@@ -6961,6 +6983,7 @@ def add_existing_edge(edge: dict) -> None:
# rather than falling back like Pascal does. Used by the #1745 warning in
# extract() to tell the user which extra restores the language.
_EXTRA_FOR_EXTENSION = {
+ ".qml": "qml",
".vb": "vbnet",
".r": "r",
".sol": "solidity",
@@ -7046,20 +7069,6 @@ def _is_objc_header(path: Path) -> bool:
return any(marker in head for marker in _OBJC_HEADER_MARKERS)
-# C++-only signals. None of these are valid in a plain C header, so finding one
-# in a `.h` is a high-confidence signal the header is C++ (#1547). The C grammar
-# has no class_specifier, so a `class Foo { ... };` header routed to extract_c
-# loses the class and its method prototypes (a junk `foo_foo` node + a sourceless
-# `class` stub); routing to extract_cpp recovers the real type. Kept CONSERVATIVE:
-# a plain C header with none of these stays on extract_c. ObjC sniffing keeps
-# priority (an ObjC header can legitimately contain `::`/`class` inside an inline
-# C++ block when compiled as Objective-C++).
-_CPP_HEADER_MARKERS = (
- b"class ", b"namespace ", b"template", b"::",
- b"public:", b"private:", b"protected:",
-)
-
-
def _is_objc_source(path: Path) -> bool:
"""Whether a `.m` file is Objective-C rather than MATLAB/Octave (#1702).
@@ -7077,20 +7086,22 @@ def _is_objc_source(path: Path) -> bool:
def _is_cpp_header(path: Path) -> bool:
"""Whether a `.h` file is C++ rather than plain C (#1547).
- Mirrors `_is_objc_header`: sniffs for a C++-only token. Used only to reroute
- a `.h` from extract_c to extract_cpp when no ObjC marker is present (ObjC has
- priority). Conservative by construction — a plain C header matches nothing
- here and keeps its existing extract_c routing.
+ Visible declaration tokens in the bounded header prefix select C++, across
+ whitespace and comments between tokens. Inert literals/comments and
+ inconclusive headers retain C. Objective-C selection has priority.
"""
- try:
- head = path.read_bytes()[:256 * 1024]
- except OSError:
- return False
- return any(marker in head for marker in _CPP_HEADER_MARKERS)
+ # Admission uses visible tokens across whitespace; strings/comments cannot
+ # select another parser. This standard-library helper needs no Qt parser.
+ from graphify.cpp_header import is_cpp_header
+ return is_cpp_header(path)
def _get_extractor(path: Path) -> Any | None:
"""Return the correct extractor function for a file, or None if unsupported."""
+ if path.name == "qmldir":
+ return extract_qmldir
+ if path.name == "CMakeLists.txt":
+ return extract_cmake
if path.name.lower().endswith(".blade.php"):
return extract_blade
# MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed
@@ -7160,8 +7171,9 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]:
Returns:
(index, result_dict) so results can be placed back in order.
"""
- if len(args) == 4:
- idx, path_str, root_str, cache_location_str = args
+ native_cache_bypass = bool(args[4]) if len(args) == 5 else False
+ if len(args) in (4, 5):
+ idx, path_str, root_str, cache_location_str = args[:4]
else: # legacy 3-tuple: location == anchor
idx, path_str, root_str = args
cache_location_str = root_str
@@ -7169,7 +7181,7 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]:
root = Path(root_str)
cache_location = Path(cache_location_str)
_raise_recursion_limit()
- bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
+ bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES or qt_syntax_cache_bypass(path, native=native_cache_bypass)
# Check cache first (avoid re-extraction)
if not bypass_cache:
@@ -7229,6 +7241,7 @@ def _extract_parallel(
max_workers: int | None,
total_files: int,
cache_location: Path | None = None,
+ native_cache_bypass: bool = False,
) -> bool:
"""Extract uncached files in parallel using ProcessPoolExecutor.
@@ -7278,7 +7291,8 @@ def _extract_parallel(
# the cache dir is written (defaults to root when not decoupled) (#1774).
root_str = str(root)
cache_loc_str = str(cache_location if cache_location is not None else root)
- work_items = [(idx, str(path), root_str, cache_loc_str) for idx, path in uncached_work]
+ work_items = [(idx, str(path), root_str, cache_loc_str, True) if native_cache_bypass
+ else (idx, str(path), root_str, cache_loc_str) for idx, path in uncached_work]
done_count = 0
failed: list[int] = [] # positions into uncached_work whose future failed
@@ -7371,6 +7385,7 @@ def _extract_sequential(
root: Path,
total_files: int,
cache_location: Path | None = None,
+ native_cache_bypass: bool = False,
) -> None:
"""Extract uncached files sequentially (fallback for small batches)."""
_PROGRESS_INTERVAL = 100
@@ -7388,7 +7403,7 @@ def _extract_sequential(
if extractor is None:
per_file[idx] = {"nodes": [], "edges": []}
continue
- bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
+ bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES or qt_syntax_cache_bypass(path, native=native_cache_bypass)
# XAML boundary anchors on `root` (the corpus), not the cache location.
result = _safe_extract_with_xaml_root(extractor, path, root)
# See _extract_single_file: don't cache an anomalous zero-node result (#1666),
@@ -7414,6 +7429,8 @@ def extract(
max_workers: int | None = None,
resolution_context_nodes: list[dict] | None = None,
resolution_context_edges: list[dict] | None = None,
+ qml_import_roots: tuple[str, ...] | None = None,
+ refresh_native: bool = False,
) -> dict:
"""Extract AST nodes and edges from a list of code files.
@@ -7454,8 +7471,10 @@ def extract(
resolution_context_nodes: they widen the resolvers' view but only
fresh results are appended to the returned nodes/edges.
"""
- paths = [Path(p) for p in paths]
anchor_root = Path(root) if root is not None else None
+ # Normalize native entry spellings before facts, worker dispatch or cache keys.
+ context_root = anchor_root if anchor_root is not None else cache_root
+ paths = [normalize_input_source(Path(p), context_root) for p in paths]
_check_tree_sitter_version()
_raise_recursion_limit()
# Workspace package manifests/globs can change during watch or repeated extraction.
@@ -7505,6 +7524,12 @@ def extract(
elif cache_root is not None:
root = cache_root
root = root.resolve()
+ # One source-name decision serves IDs and provenance; realpath state remains
+ # owned by this analysis run, with no second cache or discovery authority.
+ def _walked_rel(path, resolved=None):
+ walked = Path(os.path.abspath(path))
+ physical = resolved if resolved is not None else _cached_realpath(str(walked), os.getcwd())
+ return walked_relative_source(walked, root, physical, realpath=_cached_realpath, cwd=os.getcwd())
python_scan_paths = list(paths)
for context_node in resolution_context_nodes or []:
source_file = context_node.get("source_file")
@@ -7533,6 +7558,8 @@ def extract(
cache_location = (cache_root if cache_root is not None else Path(".")).resolve()
total = len(paths)
+ native_cache_bypass = refresh_native or requires_native_refresh(paths)
+ native_policy_kwargs = {"native_cache_bypass": True} if native_cache_bypass else {}
# Phase 1: separate cached hits from uncached work
per_file: list[dict | None] = [None] * total
uncached_work: list[tuple[int, Path]] = []
@@ -7541,7 +7568,7 @@ def extract(
if _get_extractor(path) is None:
per_file[i] = {"nodes": [], "edges": []}
continue
- bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
+ bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES or qt_syntax_cache_bypass(path, native=native_cache_bypass)
if not bypass_cache:
cached = load_cached(path, root, cache_root=cache_location)
if cached is not None:
@@ -7574,7 +7601,7 @@ def extract(
ran_parallel = False
if parallel and len(uncached_work) >= _PARALLEL_THRESHOLD:
ran_parallel = _extract_parallel(
- uncached_work, per_file, root, max_workers, total, cache_location
+ uncached_work, per_file, root, max_workers, total, cache_location, **native_policy_kwargs
)
if not ran_parallel:
# #2444: only re-extract what the pool didn't finish. A pool that
@@ -7582,7 +7609,7 @@ def extract(
# the whole batch would throw that work away.
_extract_sequential(
[(i, p) for (i, p) in uncached_work if per_file[i] is None],
- per_file, root, total, cache_location,
+ per_file, root, total, cache_location, **native_policy_kwargs,
)
# Fill any remaining None slots. With the #2444/#2445 handling above this
@@ -7596,6 +7623,11 @@ def extract(
"error": "internal: no extraction result produced",
}
+ # Overlay only already-admitted JavaScript resources referenced by QML.
+ # Its facts are separate from the generic JS graph and never execute source.
+ from graphify.extractors.qml_scripts import collect_qml_scripts
+ collect_qml_scripts(paths, per_file, root=root)
+
# #1666: surface any source file an extractor accepted but that produced zero
# nodes (not even a file node). Such a file is silently absent from the graph,
# so affected/explain are blind to and through it with no other signal.
@@ -7839,7 +7871,7 @@ def _describe_syntax_error(rel: str, line: "int | None", kept: int) -> str:
# still carry the raw file-stem prefix; the per-file prefix remap then diverges
# them (foo_h vs foo_cpp), so the collapse must happen first. Collapsing here
# also means disambiguation sees one source_file per id and won't split them.
- _merge_decl_def_classes(all_nodes, all_edges)
+ _merge_decl_def_classes(all_nodes, all_edges, all_raw_calls)
# Remap file node IDs from absolute-path-derived to the canonical
# {parent_dir}_{stem} spec form so (a) graph.json edge endpoints are stable
@@ -7877,7 +7909,7 @@ def _portable_out_of_root_sf(p: Path) -> str:
# file node is correctly relativized to main and the skill.md spec wants
# main_run -- splitting the symbol into AST/semantic ghosts (#1096). Relativize
# the symbol prefix the same way, gated by source_file so two files sharing a
- # prefix can't cross-contaminate. Keyed by resolved path -> (old_pref, new_pref).
+ # prefix can't cross-contaminate. Keyed by walked root-relative source identity.
# Each file maps from up to TWO old prefixes — the input-form prefix
# _file_node_id(path) and the absolute-resolved-form prefix
# _file_node_id(path.resolve()). Alias/workspace imports resolve specifiers
@@ -7919,9 +7951,9 @@ def _portable_out_of_root_sf(p: Path) -> str:
# Already covered: either the target is in this batch (its input
# form is the same form the extractors minted ids from, and the
# per-path loop registers both that and the resolved form) or an
- # earlier stamped edge registered it. Re-appending it here would
- # re-run its per-path iteration AFTER later batch files and could
- # flip the last-writer of a colliding old-id key.
+ # earlier stamped edge registered it. The ownership pass below
+ # handles shared forms independently; no redundant target entry
+ # should participate in its accepted source claims.
continue
_remap_seen.add(_tp)
try:
@@ -7977,15 +8009,15 @@ def _portable_out_of_root_sf(p: Path) -> str:
if _raw_tp != _tp:
_remap_seen.add(_raw_tp)
remap_paths.append(_raw_tp)
+ # Shared physical forms have one explicit owner, independent of batch order.
+ _physical_owners = resolved_source_owners(
+ remap_paths, root, walked_relative=_walked_rel, realpath=_cached_realpath, cwd=os.getcwd())
for path in remap_paths:
old_id = _make_id(str(path))
try:
- rel = path.relative_to(root)
+ rel = _walked_rel(path)
except ValueError:
- try:
- rel = path.resolve().relative_to(root)
- except ValueError:
- continue
+ continue
new_id = _file_node_id(rel)
if old_id != new_id:
id_remap[old_id] = new_id
@@ -7993,8 +8025,9 @@ def _portable_out_of_root_sf(p: Path) -> str:
# alias/workspace import targets (resolved via .resolve()) remap to
# canonical instead of orphaning (#1529).
old_id_abs = _make_id(str(path.resolve()))
- if old_id_abs != new_id:
- id_remap[old_id_abs] = new_id
+ physical_id = _file_node_id(_physical_owners[path.resolve()])
+ if old_id_abs != physical_id:
+ id_remap[old_id_abs] = physical_id
old_prefs: list[tuple[str, str]] = []
old_pref = _file_node_id(path)
if old_pref != new_id:
@@ -8010,18 +8043,19 @@ def _portable_out_of_root_sf(p: Path) -> str:
# (#2243). Register the suffixed forms, preserving the extension tail
# exactly as the prefix remap yields for in-batch entry nodes
# (`c.sh` -> `c_sh__entry`): new prefix + the tail of the minted id.
- for _old, _pref in ((old_id, old_pref), (old_id_abs, old_pref_abs)):
+ for _old, _pref, _owner in ((old_id, old_pref, new_id),
+ (old_id_abs, old_pref_abs, physical_id)):
if not _old.startswith(_pref):
continue
- _entry_new = new_id + _old[len(_pref):] + "__entry"
+ _entry_new = _owner + _old[len(_pref):] + "__entry"
_entry_old = _old + "__entry"
if _entry_old != _entry_new:
id_remap.setdefault(_entry_old, _entry_new)
if old_prefs:
- prefix_remap[path.resolve()] = old_prefs
+ prefix_remap[root / rel] = old_prefs
# Absolute form first: it is the longest, so prefix decomposition can
# try forms in order without a shorter form shadowing it.
- stem_forms[path.resolve()] = (
+ stem_forms[root / rel] = (
new_id, [old_pref_abs, old_pref, new_id]
)
if id_remap:
@@ -8073,7 +8107,7 @@ def _portable_out_of_root_sf(p: Path) -> str:
if n.get("type") == "package":
continue
try:
- entry = prefix_remap.get(Path(sf).resolve())
+ entry = prefix_remap.get(root / _walked_rel(Path(sf)))
except Exception:
continue
if entry is None:
@@ -8179,8 +8213,8 @@ def _edge_key(edge: dict) -> str:
def _decompose(target: str, tf: str) -> "tuple[str, str] | None":
try:
- forms = stem_forms.get(Path(tf).resolve())
- except (OSError, RuntimeError):
+ forms = stem_forms.get(root / _walked_rel(Path(tf)))
+ except (OSError, RuntimeError, ValueError):
return None
if not forms:
return None
@@ -8424,6 +8458,11 @@ def _looks_like_bash(result: object) -> bool:
for n in resolution_nodes:
if n.get("file_type") == "rationale" or n.get("type") == "namespace":
continue
+ # Dedicated QML scopes resolve these facts; shared label guessing must
+ # not bind generic language calls to QML declarations or source sites.
+ _qml_metadata = n.get("metadata", {}).get("qml", {})
+ if _qml_metadata.get("contract_version") == 1:
+ continue
raw = n.get("label", "")
normalised = raw.strip("()").lstrip(".")
if normalised:
@@ -8895,24 +8934,25 @@ def _sf_entry(sf: str, sf_path: Path) -> tuple[str, str, tuple[str, ...]]:
cached = _sf_forms.get(sf)
if cached is not None:
return cached
+ # Accepted aliases identify the same physical source as the canonical
+ # scan root. Decide containment before the external-path fallback so a
+ # nearby short/symlink parent cannot turn native provenance into ../..
+ # transport. Keep written and resolved forms for existing endpoint joins.
try:
- rel = sf_path.relative_to(root)
+ sf_resolved = _cached_realpath(str(sf_path), os.getcwd())
+ except (OSError, RuntimeError):
+ sf_resolved = sf_path
+ try:
+ rel = _walked_rel(sf_path, sf_resolved)
except ValueError:
portable = _portable_out_of_root_sf(sf_path)
canonical_id = _make_id("ext", portable)
new_sf = portable
else:
- # In-root: the same canonical repo-relative form the real file
- # node uses (_file_node_id), so the scan root can never leak into
- # a persisted id. Real file nodes were already remapped by the
- # #2169 pass, so only leftover absolute-derived ids match below
- # (belt-and-braces for #2195 regex-rescue stubs and friends).
+ # IDs and source/definition provenance use the same physically
+ # admitted walked identity, including distinct discovered aliases.
canonical_id = _file_node_id(rel)
new_sf = rel.as_posix()
- try:
- sf_resolved = sf_path.resolve()
- except (OSError, RuntimeError):
- sf_resolved = sf_path
# Learn the STEM (extension-dropped) forms too: symbol producers mint
# compound ids as _make_id(_file_stem(path), name), so a node-less
# absolute-derived endpoint arrives as _ and only
@@ -9019,6 +9059,16 @@ def _canon(nid: str) -> str:
e.pop("local_alias", None)
e.pop("_python_plain_import", None)
+ # Native overlays must borrow final canonical C++ identities, not the
+ # earlier absolute/stem IDs. Context remains accepted, read-only input.
+ from graphify.qt_qml_pipeline import resolve_qt_qml
+ for failed in resolve_qt_qml(paths, per_file, all_nodes, all_edges, root=root,
+ import_roots=qml_import_roots,
+ context_nodes=resolution_context_nodes,
+ context_edges=resolution_context_edges):
+ if failed not in _failed_sources:
+ _failed_sources.append(failed)
+
# Tag AST provenance so the incremental watch rebuild can distinguish
# AST-extracted nodes from semantic/LLM nodes. On a full re-extraction
# the watcher drops any AST-marked node missing from the fresh output
@@ -9068,6 +9118,12 @@ def _canon(nid: str) -> str:
# manifest does not freeze them as processed (#2543). Callers that
# only read nodes/edges ignore this key.
"failed_sources": _failed_sources,
+ "qml_failures": [failure for result in per_file if result
+ for failure in result.get("qml_failures", [])],
+ "qt_failures": [failure for result in per_file if result
+ for failure in result.get("qt_failures", [])],
+ "diagnostics": [diagnostic for result in per_file if result
+ for diagnostic in result.get("diagnostics", [])],
# Surfaces the actual dispatched source paths so build_merge /
# merge_raw_extraction know which files were genuinely re-extracted
# rather than guessing ownership from node["source_file"] (#3411).
@@ -9079,6 +9135,12 @@ def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | N
containment_root = root if root is not None else target
from graphify.detect import _resolves_under_root
if target.is_file():
+ if is_qml_path(target):
+ from graphify.detect import _is_ignored, _load_graphifyignore
+ ignore_root = root if root is not None else target.parent
+ patterns = _load_graphifyignore(ignore_root)
+ if patterns and _is_ignored(target, ignore_root, patterns):
+ return []
return [target] if _resolves_under_root(target, containment_root) else []
_EXTENSIONS = set(_DISPATCH.keys())
from graphify.detect import (
@@ -9116,7 +9178,12 @@ def _ignored(p: Path) -> bool:
for fname in filenames:
p = dp / fname
suffix = p.suffix
- if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and not _is_installed_graphify_file(p) and _resolves_under_root(p, containment_root):
+ # Exact-name Qt manifests share ordinary sources' corpus gate;
+ # installed Graphify copies never become application facts.
+ if ((suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS
+ or p.name in {"qmldir", "CMakeLists.txt"})
+ and not _ignored(p) and not _is_installed_graphify_file(p)
+ and _resolves_under_root(p, containment_root)):
results.append(p)
return sorted(results)
# Walk with symlink following + cycle detection
@@ -9137,7 +9204,11 @@ def _ignored(p: Path) -> bool:
for fname in filenames:
p = dp / fname
suffix = p.suffix
- if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and not _is_installed_graphify_file(p) and _resolves_under_root(p, containment_root):
+ # Following a contained alias preserves the same admission gate.
+ if ((suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS
+ or p.name in {"qmldir", "CMakeLists.txt"})
+ and not _ignored(p) and not _is_installed_graphify_file(p)
+ and _resolves_under_root(p, containment_root)):
results.append(p)
return sorted(results)
diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py
index e2c8afc627..d1ef54605f 100644
--- a/graphify/extractors/__init__.py
+++ b/graphify/extractors/__init__.py
@@ -29,6 +29,12 @@
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest
from graphify.extractors.r import extract_r
+from graphify.extractors.qml import extract_qml
+from graphify.extractors.qml_metadata import extract_qmldir
+from graphify.extractors.qml_cmake import extract_cmake
+from graphify.extractors.qml_qmake import extract_qmake
+from graphify.extractors.qml_resources import extract_qrc
+from graphify.extractors.qml_types import extract_qmltypes
from graphify.extractors.razor import extract_razor
from graphify.extractors.rust import extract_rust
from graphify.extractors.sln import extract_sln
@@ -40,6 +46,12 @@
from graphify.extractors.zig import extract_zig
LANGUAGE_EXTRACTORS: dict[str, Callable[[Path], dict]] = {
+ "qml": extract_qml,
+ "qmldir": extract_qmldir,
+ "cmake": extract_cmake,
+ "qmake": extract_qmake,
+ "qrc": extract_qrc,
+ "qmltypes": extract_qmltypes,
"apex": extract_apex,
"bash": extract_bash,
"blade": extract_blade,
diff --git a/graphify/extractors/cpp_class_proof.py b/graphify/extractors/cpp_class_proof.py
new file mode 100644
index 0000000000..2a8f0e604b
--- /dev/null
+++ b/graphify/extractors/cpp_class_proof.py
@@ -0,0 +1,34 @@
+"""Accepted C++ class source proof guards mapping and sibling canonicalization."""
+from __future__ import annotations
+
+from graphify.extractors.cpp_constructors import _decoded, _fact, _span
+
+
+def class_fact(node):
+ """Share the existing bounded transport/span/label contract, not a basename."""
+ return _fact(node, "cpp_class") if node.get("_callable_class") is True else {}
+
+
+def class_matches(node, qualified, syntax):
+ fact = class_fact(node)
+ if not fact or _decoded(fact["qualified_name_b64"]) != qualified:
+ return False
+ # A forward declaration denotes its exact qualified canonical class. A
+ # complete body's authority additionally requires its original byte span.
+ return syntax.child_by_field_name("body") is None or fact["span"] == _span(syntax)
+
+
+def class_merge_allowed(group):
+ facts = [class_fact(node) for node in group]
+ if not any(isinstance(node.get("metadata"), dict) and "cpp_class" in node["metadata"] for node in group):
+ return True
+ if any(not fact or fact["ambiguous"] for fact in facts):
+ return False
+ return (len({_decoded(fact["qualified_name_b64"]) for fact in facts}) == 1
+ and sum(fact["is_definition"] for fact in facts) <= 1)
+
+
+def class_body_keeper(group, default):
+ """A forward header must not replace an implementation's complete-body span."""
+ complete = [node for node in group if class_fact(node).get("is_definition") is True]
+ return complete[0] if len(complete) == 1 else default
diff --git a/graphify/extractors/cpp_constructor_binding.py b/graphify/extractors/cpp_constructor_binding.py
new file mode 100644
index 0000000000..30a413faf7
--- /dev/null
+++ b/graphify/extractors/cpp_constructor_binding.py
@@ -0,0 +1,118 @@
+"""Exact parent and signature proof for constructor declaration/body joins."""
+from __future__ import annotations
+
+import hashlib
+
+from graphify.extractors.base import _make_id
+from graphify.extractors.cpp_identity import qualified_class_id
+from graphify.extractors.cpp_constructor_signature import constructor_signature
+
+
+def within_class(node, fact, owner):
+ """An extracted parent edge cannot authorize a prototype relocated outside its source class."""
+ span, parent_span = fact["span"], owner["metadata"]["cpp_class"]["span"]
+ return (owner.get("source_file") == node.get("source_file")
+ and parent_span["start_byte"] < span["start_byte"] < span["end_byte"] < parent_span["end_byte"]
+ and (parent_span["start_row"], parent_span["start_column"]) < (span["start_row"], span["start_column"])
+ and (span["end_row"], span["end_column"]) < (parent_span["end_row"], parent_span["end_column"]))
+
+
+def inline_owner(node, fact, owners, edges):
+ """An inline body requires one complete source-owned containing class."""
+ if fact.get("ambiguous") or fact.get("owner_bound") is not True or len(owners) != 1:
+ return False
+ owner = owners[0]
+ return (within_class(node, fact, owner)
+ and any(edge.get("source") == owner["id"] and edge.get("target") == node["id"]
+ and edge.get("relation") == "method" and edge.get("confidence") == "EXTRACTED"
+ and edge.get("source_file") == node.get("source_file")
+ and edge.get("source_location") == node.get("source_location") for edge in edges))
+
+
+def id_authorized(node, owner, signature):
+ """Signature transport cannot redirect an occurrence into another class."""
+ expected = _make_id(qualified_class_id("", owner), owner.rsplit("::", 1)[-1], "cppctor", signature)
+ # Only the file prefix changes during root remapping. Owner and signature
+ # must remain the exact producer suffix before and after that operation.
+ return node["id"] == expected or str(node["id"]).endswith("_" + expected)
+
+
+def syntax_matches(node, declaration, source, owner, *, definition_site=False):
+ """A Qt source occurrence selects its actual signature and original span."""
+ from graphify.extractors.cpp_constructors import _fact, _span
+ fact = _fact(node, "cpp_constructor")
+ if not fact:
+ return False
+ payload = constructor_signature(declaration, source, owner)
+ if not payload or fact.get("ambiguous") or hashlib.sha256(payload).hexdigest() != fact["signature"]:
+ return False
+ span = fact.get("definition_span") if definition_site else fact["span"]
+ return span == _span(declaration)
+
+
+def bind_constructors(nodes, edges):
+ """Join unique complete classes and exact matching overload declarations."""
+ from graphify.extractors.cpp_constructors import _fact, _decoded
+ from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority
+ classes = {}
+ declarations = {}
+ type_authority = ConstructorTypeAuthority(nodes)
+ class_ids = {node["id"] for node in nodes if node.get("_callable_class") is True}
+ for node in nodes:
+ fact = _fact(node, "cpp_class")
+ name = _decoded(fact.get("qualified_name_b64"))
+ if (name and fact.get("is_definition") is True and not fact.get("ambiguous")
+ and node.get("_callable_class") is True and type_authority.source_span_authorized(node, fact)):
+ classes.setdefault(name, []).append(node)
+ fact = _fact(node, "cpp_constructor")
+ if fact.get("role") == "declaration":
+ fact["type_authorized"] = type_authority.authorized(node, fact)
+ declarations.setdefault(_decoded(fact.get("owner_b64")), []).append(node)
+ for node in nodes:
+ fact = _fact(node, "cpp_constructor")
+ if fact.get("role") != "definition":
+ continue
+ if not type_authority.authorized(node, fact):
+ fact.update(owner_bound=False, type_authorized=False)
+ continue
+ fact["type_authorized"] = True
+ owners = classes.get(_decoded(fact.get("owner_b64")), [])
+ if inline_owner(node, fact, owners, edges):
+ continue # Accepted inline body already has its exact class parent.
+ fact["owner_bound"] = False
+ prototypes = [item for item in declarations.get(_decoded(fact.get("owner_b64")), [])
+ if _fact(item, "cpp_constructor").get("signature") == fact.get("signature")]
+ if len(owners) != 1 or len(prototypes) != 1 or fact.get("ambiguous"):
+ continue
+ prototype = prototypes[0]
+ declaration = _fact(prototype, "cpp_constructor")
+ owner = owners[0]
+ if (declaration.get("ambiguous") or declaration.get("signature") != fact.get("signature")
+ or not type_authority.authorized(prototype, declaration)
+ or not within_class(prototype, declaration, owner)
+ or not any(edge.get("source") == owner["id"] and edge.get("target") == prototype["id"]
+ and edge.get("relation") == "method" and edge.get("confidence") == "EXTRACTED"
+ and edge.get("source_file") == owner.get("source_file")
+ and edge.get("source_location") == prototype.get("source_location")
+ for edge in edges)):
+ continue
+ # Definition provenance and any pre-existing real parent remain authoritative.
+ if (not any(edge.get("target") == node["id"] and edge.get("relation") == "contains"
+ and edge.get("confidence") == "EXTRACTED" and edge.get("source_file") == node.get("source_file")
+ for edge in edges)
+ or any(edge.get("target") == node["id"] and (edge.get("relation") == "method"
+ or (edge.get("relation") == "contains" and edge.get("source") in class_ids))
+ and edge.get("confidence") == "EXTRACTED" and edge.get("source") != owner["id"]
+ for edge in edges)
+ or any(edge.get("target") == prototype["id"] and (edge.get("relation") == "method"
+ or (edge.get("relation") == "contains" and edge.get("source") in class_ids))
+ and edge.get("confidence") == "EXTRACTED" and edge.get("source") != owner["id"]
+ for edge in edges)):
+ continue
+ fact["owner_bound"] = True
+ declaration["definition_span"] = dict(fact["span"])
+ if not any(edge.get("source") == owner["id"] and edge.get("target") == node["id"]
+ and edge.get("relation") == "method" for edge in edges):
+ edges.append({"source": owner["id"], "target": node["id"], "relation": "method",
+ "confidence": "EXTRACTED", "context": "cpp_constructor_owner",
+ "source_file": owner["source_file"], "source_location": prototype["source_location"], "weight": 1.0})
diff --git a/graphify/extractors/cpp_constructor_signature.py b/graphify/extractors/cpp_constructor_signature.py
new file mode 100644
index 0000000000..07fe39a093
--- /dev/null
+++ b/graphify/extractors/cpp_constructor_signature.py
@@ -0,0 +1,138 @@
+"""Bounded constructor signature identity before generic node deduplication.
+
+The accepted profile covers builtin, self-class and QObject parameter types with
+literal pointer/reference qualifiers. Unsupported types retain occurrence IDs;
+they never authorize a declaration/definition join. No compiler or corpus runs.
+"""
+from __future__ import annotations
+
+import base64
+import binascii
+import hashlib
+import re
+
+from graphify.extractors.base import _make_id, _read_text
+
+
+def _builtin(value):
+ words = value.split()
+ single = {"bool", "char", "wchar_t", "char8_t", "char16_t", "char32_t", "float", "double", "void"}
+ if len(words) == 1 and words[0] in single:
+ return words[0]
+ if words and all(word in {"signed", "unsigned", "short", "long", "int", "char", "double"} for word in words):
+ if (words.count("long") > 2 or any(words.count(word) > 1 for word in set(words) - {"long"})
+ or {"signed", "unsigned"} <= set(words) or {"short", "long"} <= set(words)):
+ return ""
+ if "double" in words:
+ return "long double" if sorted(words) == ["double", "long"] else ""
+ if "char" in words:
+ return " ".join(sorted(words)) if len(words) == 2 and set(words) <= {"signed", "unsigned", "char"} else ""
+ sign = "unsigned " if "unsigned" in words else ""
+ size = "short" if "short" in words else "long long" if words.count("long") == 2 else "long" if "long" in words else "int"
+ return sign + size
+ return ""
+
+
+def _parameter(parameter, source, owner):
+ """Remove value-only CV and names without erasing pointee/reference types."""
+ if parameter.type not in {"parameter_declaration", "optional_parameter_declaration"}:
+ return ""
+ type_node = parameter.child_by_field_name("type")
+ if type_node is None or parameter.end_byte - parameter.start_byte > 1024:
+ return ""
+ raw_type = re.sub(r"\s+", " ", _read_text(type_node, source)).strip()
+ base = _builtin(raw_type)
+ if not base:
+ # Aliases and unrelated named types need semantic proof beyond spelling.
+ if raw_type in {owner, owner.rsplit("::", 1)[-1]}:
+ base = "self:" + owner
+ elif raw_type == "QObject":
+ base = "QObject"
+ else:
+ return ""
+ qualifier = sorted(_read_text(child, source) for child in parameter.children if child.type == "type_qualifier")
+ if any(value not in {"const", "volatile"} for value in qualifier):
+ return ""
+ declarator = parameter.child_by_field_name("declarator")
+ layers = []
+ while declarator and declarator.type not in {"identifier", "field_identifier"}:
+ if declarator.type not in {"pointer_declarator", "reference_declarator", "abstract_pointer_declarator", "abstract_reference_declarator"}:
+ return ""
+ cv = sorted(_read_text(child, source) for child in declarator.children if child.type == "type_qualifier")
+ if any(value not in {"const", "volatile"} for value in cv):
+ return ""
+ symbol = "*" if "pointer" in declarator.type else "&&" if any(child.type == "&&" for child in declarator.children) else "&"
+ layers.append([symbol, cv])
+ declarator = declarator.child_by_field_name("declarator") or next(
+ (child for child in declarator.named_children if child.type != "type_qualifier"), None)
+ if layers and layers[-1][0] == "*":
+ layers[-1][1] = [] # Last lexical layer is the value-level pointer.
+ # C++ permits a sole unnamed unqualified void as an empty parameter list;
+ # value-CV normalization must not turn an invalid qualified void into it.
+ if base == "void" and not layers and qualifier:
+ return ""
+ if not layers:
+ qualifier = []
+ if base == "void" and not layers and parameter.child_by_field_name("declarator") is not None:
+ return ""
+ if base == "void" and (layers or qualifier):
+ return "" if not layers else " ".join(qualifier + [base]) + "".join(symbol + " ".join(cv) for symbol, cv in layers)
+ return " ".join(qualifier + [base]) + "".join(symbol + " ".join(cv) for symbol, cv in layers)
+
+
+def constructor_signature(node, source, owner):
+ """Return accepted signature bytes or no authority for unsupported syntax."""
+ declarator = node.child_by_field_name("declarator")
+ if declarator is None or declarator.type != "function_declarator" or node.has_error:
+ return b""
+ parent = node.parent
+ while parent:
+ if parent.type == "template_declaration" or parent.type.startswith("preproc_"):
+ return b""
+ parent = parent.parent
+ if any(child.type == "requires_clause" for child in declarator.named_children):
+ return b""
+ params = declarator.child_by_field_name("parameters")
+ if params is None or len(params.named_children) > 64 or any(child.type == "..." for child in params.children):
+ return b""
+ values = [_parameter(item, source, owner) for item in params.named_children]
+ if any(not value for value in values) or "void" in values and values != ["void"]:
+ return b""
+ # An explicit version marker distinguishes accepted zero arguments from an
+ # unsupported signature, and bounds transport below metadata string limits.
+ payload = ("ctor2\0" + "\0".join([] if values == ["void"] else values)).encode()
+ return payload if len(payload) <= 360 else b""
+
+
+def constructor_id(class_id, name, node, source, owner):
+ """IDs never depend on the number or ordering of neighboring overloads."""
+ payload = constructor_signature(node, source, owner)
+ if not payload:
+ # A source-owned unsupported occurrence must not collapse onto another
+ # callable or become a plausible exact signature through normalization.
+ payload = b"unsupported\0" + str(node.start_byte).encode() + b"\0" + source[node.start_byte:node.end_byte]
+ digest = hashlib.sha256(payload).hexdigest()
+ return _make_id(class_id, name, "cppctor", digest)
+
+
+def signature_fact(node, source, owner):
+ payload = constructor_signature(node, source, owner)
+ return {"signature": hashlib.sha256(payload).hexdigest() if payload else hashlib.sha256(
+ b"unsupported\0" + str(node.start_byte).encode() + b"\0" + source[node.start_byte:node.end_byte]).hexdigest(),
+ "signature_b64": base64.b64encode(payload).decode(), "ambiguous": not bool(payload)}
+
+
+def signature_authorized(node, fact):
+ """Reject corrupted transport and copying another overload's signature."""
+ value = fact.get("signature_b64")
+ if not isinstance(value, str) or not value or len(value) > 480:
+ return False
+ try:
+ payload = base64.b64decode(value, validate=True)
+ decoded = payload.decode("utf-8")
+ except (ValueError, UnicodeError, binascii.Error):
+ return False
+ if (not decoded.startswith("ctor2\0") or len(payload) > 360
+ or hashlib.sha256(payload).hexdigest() != fact.get("signature")):
+ return False
+ return str(node.get("id", "")).endswith("_cppctor_" + fact["signature"])
diff --git a/graphify/extractors/cpp_constructor_type_authority.py b/graphify/extractors/cpp_constructor_type_authority.py
new file mode 100644
index 0000000000..d722e4c7eb
--- /dev/null
+++ b/graphify/extractors/cpp_constructor_type_authority.py
@@ -0,0 +1,236 @@
+"""Accepted AST type shadows for the bounded constructor QObject signature.
+
+File facts retain literal includes and namespace/class shadows before canonical
+joins. The corpus lookup consumes only admitted nodes; it discovers no files,
+opens no headers and does not emulate preprocessing or resolve alias targets.
+"""
+from __future__ import annotations
+
+import base64
+import binascii
+import posixpath
+import re
+
+from graphify.extractors.base import _read_text
+from graphify.extractors.cpp_identity import lexical_scope
+
+
+def _walk(root):
+ pending = [root]
+ while pending:
+ node = pending.pop()
+ yield node
+ pending.extend(reversed(node.named_children))
+
+
+def _encode(value):
+ return base64.b64encode(value.encode()).decode()
+
+
+def _decode(value):
+ if not isinstance(value, str) or len(value) > 480:
+ return None
+ try:
+ decoded = base64.b64decode(value, validate=True).decode("utf-8")
+ except (ValueError, UnicodeError, binascii.Error):
+ return None
+ return decoded if len(decoded.encode()) <= 360 else None
+
+
+def source_facts(root, source, nodes, path):
+ """The direct generic producer owns the transport, not the Qt facade."""
+ from graphify.extractors.cpp_constructors import _span
+ files = [node for node in nodes if node.get("source_file") == str(path)
+ and node.get("source_location") == "L1" and node.get("label") == path.name
+ and not node.get("_callable") and not node.get("_callable_class")]
+ if len(files) != 1:
+ return
+ shadows, includes, complete = [], [], not root.has_error
+ for syntax in _walk(root):
+ if syntax.type == "preproc_include":
+ literal = _read_text(syntax.child_by_field_name("path"), source)
+ kind = "quoted" if literal.startswith('"') and literal.endswith('"') else "angle" if literal.startswith("<") and literal.endswith(">") else ""
+ if kind:
+ value = literal[1:-1]
+ if (not value or len(value.encode()) > 360 or "\\" in value or ":" in value
+ or value.startswith("/") or "\0" in value or kind == "angle" and ".." in value.split("/")):
+ complete = False
+ else:
+ includes.append({"literal_b64": _encode(value), "kind": kind,
+ "end_byte": syntax.end_byte, "span": _span(syntax)})
+ else:
+ complete = False
+ continue
+ if syntax.type not in {"alias_declaration", "type_definition", "using_declaration", "class_specifier", "struct_specifier"}:
+ continue
+ names = syntax.children_by_field_name("declarator") if syntax.type == "type_definition" else [
+ syntax.child_by_field_name("name")]
+ if syntax.type == "using_declaration":
+ literal = _read_text(syntax, source).removeprefix("using").strip().removesuffix(";").strip()
+ names = []
+ if literal.startswith("namespace "):
+ complete = False # A namespace import may supply an unseen alias.
+ elif literal.rsplit("::", 1)[-1] == "QObject":
+ names = [syntax]
+ for name in names:
+ leaf = name
+ while leaf and leaf.type not in {"identifier", "type_identifier", "using_declaration"}:
+ leaf = leaf.child_by_field_name("declarator") or next(iter(leaf.named_children), None)
+ spelling = _read_text(leaf, source) if leaf else ""
+ if spelling != "QObject" and not (leaf and leaf.type == "using_declaration"):
+ continue
+ scope = lexical_scope(syntax, source)
+ ancestor = syntax.parent
+ while ancestor and ancestor.type not in {"function_definition", "lambda_expression", "translation_unit"}:
+ ancestor = ancestor.parent
+ if ancestor and ancestor.type in {"function_definition", "lambda_expression"}:
+ continue # A body-local type cannot shadow its constructor parameter clause.
+ if scope is None or len(scope.encode()) > 360:
+ complete = False
+ else:
+ parent = syntax.parent
+ while parent and parent.type not in {"class_specifier", "struct_specifier", "namespace_definition", "translation_unit"}:
+ parent = parent.parent
+ shadows.append({"scope_b64": _encode(scope), "start_byte": syntax.start_byte,
+ "class_scope": bool(parent and parent.type in {"class_specifier", "struct_specifier"}),
+ "span": _span(syntax)})
+ if len(includes) > 50 or len(shadows) > 50:
+ complete = False
+ metadata = dict(files[0].get("metadata") or {})
+ metadata["cpp_constructor_types"] = {"contract_version": 1, "complete": complete, "source_size": len(source),
+ "includes": includes[:50], "shadows": shadows[:50]}
+ files[0]["metadata"] = metadata
+
+
+def valid_source_transport(value):
+ """Validate file transport without granting its incomplete record type authority.
+
+ Both constructor joins and Qt file-role consumers use the same immutable,
+ bounded include/shadow validation. This predicate reads only accepted facts.
+ """
+ if (not isinstance(value, dict) or type(value.get("contract_version")) is not int
+ or value["contract_version"] != 1 or type(value.get("complete")) is not bool
+ or type(value.get("source_size")) is not int or value["source_size"] < 0):
+ return False
+ for key in ("includes", "shadows"):
+ values = value.get(key)
+ if not isinstance(values, list) or len(values) > 50:
+ return False
+ for item in values:
+ text_key, byte_key = ("literal_b64", "end_byte") if key == "includes" else ("scope_b64", "start_byte")
+ if (not isinstance(item, dict) or _decode(item.get(text_key)) is None
+ or type(item.get(byte_key)) is not int or item[byte_key] < 0):
+ return False
+ span = item.get("span")
+ if (not isinstance(span, dict) or any(type(span.get(field)) is not int or span[field] < 0
+ for field in ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column"))
+ or not span["start_byte"] < span["end_byte"] <= value["source_size"]
+ or span["start_row"] > span["end_row"] or item[byte_key] != span[byte_key]
+ or (span["start_row"] == span["end_row"]
+ and span["end_column"] - span["start_column"] != span["end_byte"] - span["start_byte"])):
+ return False
+ text = _decode(item[text_key])
+ if key == "shadows" and text and not all(part.isidentifier() for part in text.split("::")):
+ return False
+ if key == "shadows" and type(item.get("class_scope")) is not bool:
+ return False
+ if key == "includes" and (not text or "\\" in text or ":" in text or text.startswith("/") or "\0" in text
+ or item.get("kind") not in {"quoted", "angle"}
+ or item["kind"] == "angle" and ".." in text.split("/")):
+ return False
+ return True
+
+
+class ConstructorTypeAuthority:
+ """Immutable admitted-source inventory; bounded conservative include walk."""
+
+ def __init__(self, nodes):
+ self.files = {}
+ for node in nodes:
+ if node.get("source_file") and not node.get("_callable") and not node.get("_callable_class"):
+ file = self._file(node["source_file"])
+ metadata = node.get("metadata")
+ if (isinstance(metadata, dict) and "cpp_constructor_types" in metadata
+ or node.get("source_location") == "L1" and node.get("label") == posixpath.basename(file)):
+ self.files.setdefault(self._file(node["source_file"]), []).append(node)
+
+ @staticmethod
+ def _file(value):
+ return posixpath.normpath(str(value).replace("\\", "/"))
+
+ def _source_record(self, file):
+ """File bounds remain usable when include/type proof is conservatively incomplete."""
+ found = self.files.get(file, ())
+ if len(found) != 1:
+ return None
+ node = found[0]
+ metadata = node.get("metadata")
+ value = metadata.get("cpp_constructor_types") if isinstance(metadata, dict) else None
+ if (not isinstance(value, dict) or type(value.get("contract_version")) is not int
+ or value["contract_version"] != 1 or type(value.get("complete")) is not bool
+ or type(value.get("source_size")) is not int or value["source_size"] < 0
+ or node.get("source_location") != "L1" or node.get("label") != posixpath.basename(file)
+ or node.get("file_type") != "code" or node.get("_origin") not in {None, "ast"}):
+ return None
+ return value
+
+ def source_span_authorized(self, node, fact):
+ """Transported class/constructor spans must stay inside one admitted original file."""
+ record = self._source_record(self._file(node.get("source_file", "")))
+ span = fact.get("span")
+ fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column")
+ return bool(record is not None and isinstance(span, dict)
+ and all(type(span.get(field)) is int and 0 <= span[field] <= record["source_size"]
+ for field in fields)
+ and span["start_byte"] < span["end_byte"]
+ and (span["start_row"], span["start_column"]) <= (span["end_row"], span["end_column"]))
+
+ def _fact(self, file):
+ value = self._source_record(file)
+ if value is None or value["complete"] is not True:
+ return None
+ return value if valid_source_transport(value) else None
+
+ def authorized(self, node, fact):
+ if not self.source_span_authorized(node, fact):
+ return False
+ encoded = fact.get("signature_b64", "")
+ payload = _decode(encoded)
+ if payload is None or not re.search(r"(? 128 or (record := self._fact(file)) is None:
+ return False
+ if any(_decode(item["scope_b64"]) in scopes and (item["class_scope"] or item["start_byte"] <= position)
+ for item in record["shadows"]):
+ return False
+ for include in record["includes"]:
+ if include["end_byte"] > position:
+ continue
+ literal = _decode(include["literal_b64"])
+ if not literal:
+ return False
+ if include["kind"] == "angle":
+ # Configured compiler include search is unavailable here.
+ # Any admitted suffix candidate could supply a source alias,
+ # even when its transport is missing, corrupt or duplicated.
+ suffix = posixpath.normpath(literal).casefold()
+ if any(candidate.casefold() == suffix or candidate.casefold().endswith("/" + suffix)
+ for candidate in self.files):
+ return False
+ continue # An unavailable external SDK header grants no new source shadow.
+ target = posixpath.normpath(posixpath.join(posixpath.dirname(file), literal))
+ if target not in self.files:
+ return False # Missing quoted source cannot establish type identity.
+ pending.append((target, 2**63 - 1))
+ return True
diff --git a/graphify/extractors/cpp_constructors.py b/graphify/extractors/cpp_constructors.py
new file mode 100644
index 0000000000..955daeed2a
--- /dev/null
+++ b/graphify/extractors/cpp_constructors.py
@@ -0,0 +1,255 @@
+"""Bounded AST constructor declarations and exact accepted class ownership.
+
+Constructor IDs retain exact bounded signatures before generic deduplication.
+This seam supplies prototypes and source proof; unsupported signatures stay unknown.
+"""
+from __future__ import annotations
+
+import base64
+import binascii
+import hashlib
+import re
+
+from graphify.extractors.base import _file_stem, _read_text
+from graphify.extractors.cpp_identity import CppIdentity, qualified_function_id
+from graphify.extractors.cpp_constructor_signature import constructor_id, signature_authorized, signature_fact
+from graphify.extractors.cpp_constructor_binding import id_authorized
+from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority, source_facts
+
+
+def _walk(root):
+ pending = [root]
+ while pending:
+ node = pending.pop()
+ yield node
+ pending.extend(reversed(node.named_children))
+
+
+def _span(node):
+ return {"start_byte": node.start_byte, "end_byte": node.end_byte,
+ "start_row": node.start_point.row, "end_row": node.end_point.row,
+ "start_column": node.start_point.column, "end_column": node.end_point.column}
+
+
+def _identifier(value):
+ return bool(value and len(value.encode("utf-8")) <= 384
+ and all(part.isidentifier() for part in value.split("::")))
+
+
+def _encoded(value):
+ return base64.b64encode(value.encode("utf-8")).decode("ascii")
+
+
+def _decoded(value):
+ if not isinstance(value, str) or len(value) > 512:
+ return ""
+ try:
+ result = base64.b64decode(value, validate=True).decode("utf-8")
+ except (ValueError, UnicodeError, binascii.Error):
+ return ""
+ return result if _identifier(result) else ""
+
+
+def _scope(node, source):
+ """Only lexical named namespace/class scopes authorize qualified ownership."""
+ parts = []
+ parent = node.parent
+ while parent:
+ if parent.type in {"function_definition", "lambda_expression"}:
+ return None
+ if parent.type in {"namespace_definition", "class_specifier", "struct_specifier"}:
+ name_node = parent.child_by_field_name("name")
+ name = _read_text(name_node, source) if name_node else ""
+ if not _identifier(name):
+ return None
+ parts.append(name)
+ parent = parent.parent
+ return "::".join(reversed(parts))
+
+
+def _signature(declarator, source):
+ params = declarator.child_by_field_name("parameters")
+ if params is None or len(params.named_children) > 64 or any(child.type == "..." for child in params.children):
+ return ""
+ values = []
+ for parameter in params.named_children:
+ if parameter.type not in {"parameter_declaration", "optional_parameter_declaration", "variadic_parameter_declaration"}:
+ return ""
+ end = parameter.end_byte
+ default = parameter.child_by_field_name("default_value")
+ if default:
+ end = source.rfind(b"=", parameter.start_byte, default.start_byte)
+ if end < parameter.start_byte or end - parameter.start_byte > 512:
+ return ""
+ name = parameter.child_by_field_name("declarator")
+ while name and name.type not in {"identifier", "field_identifier"}:
+ name = name.child_by_field_name("declarator") or next(
+ (child for child in name.named_children if child.type in {"identifier", "field_identifier", "reference_declarator", "pointer_declarator", "parenthesized_declarator", "function_declarator"}), None)
+ text = source[parameter.start_byte:end]
+ if name:
+ offset = name.start_byte - parameter.start_byte
+ text = text[:offset] + text[offset + name.end_byte - name.start_byte:]
+ # Preserve word boundaries: `unsigned int` is not the alias `unsignedint`.
+ text = re.sub(rb"\s+", b" ", text).strip()
+ values.append(re.sub(rb"\s*([*&<>,:\[\]()])\s*", rb"\1", text))
+ if values == [b"void"]:
+ values = []
+ return hashlib.sha256(b"\0".join(values)).hexdigest()
+
+
+def _fact(node, key):
+ metadata = node.get("metadata")
+ value = metadata.get(key) if isinstance(metadata, dict) else None
+ if (not isinstance(value, dict) or type(value.get("contract_version")) is not int
+ or value["contract_version"] != 1 or type(value.get("ambiguous")) is not bool):
+ return {}
+ span = value.get("span")
+ fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column")
+ if (not isinstance(span, dict) or any(type(span.get(field)) is not int or span[field] < 0 for field in fields)
+ or span["end_byte"] <= span["start_byte"] or span["end_row"] < span["start_row"]
+ or (span["end_row"] == span["start_row"] and span["end_column"] - span["start_column"] != span["end_byte"] - span["start_byte"])):
+ return {}
+ if not node.get("source_file") or node.get("source_location") != f"L{span['start_row'] + 1}":
+ return {}
+ scope_value = value.get("scope_b64")
+ scope = "" if scope_value == "" else _decoded(scope_value)
+ if not isinstance(scope_value, str) or (scope_value and not scope):
+ return {}
+ if key == "cpp_class":
+ valid = type(value.get("is_definition")) is bool and _decoded(value.get("qualified_name_b64"))
+ expected = f"{scope}::{node.get('label')}" if scope else node.get("label")
+ valid = valid and _decoded(value["qualified_name_b64"]) == expected
+ else:
+ valid = (isinstance(value.get("role"), str) and value["role"] in {"declaration", "definition"} and _decoded(value.get("owner_b64"))
+ and node.get("_callable") is True and node.get("_callable_class") is not True
+ and isinstance(value.get("signature"), str) and re.fullmatch(r"[0-9a-f]{64}", value["signature"]))
+ name = _decoded(value.get("name_b64"))
+ label = str(node.get("label", "")).removesuffix("()").removeprefix(".")
+ owner = _decoded(value.get("owner_b64"))
+ original_owner, _, short = name.rpartition("::")
+ expected = f"{scope}::{original_owner}" if scope and original_owner else original_owner or scope
+ valid = (valid and name == label and owner.split("::")[-1] == (short if original_owner else name)
+ and owner == expected)
+ if key == "cpp_constructor":
+ valid = valid and (value["ambiguous"] or signature_authorized(node, value)) and id_authorized(node, owner, value["signature"])
+ return value if valid else {}
+
+
+def _store(node, key, fact):
+ metadata = dict(node.get("metadata") or {})
+ previous = metadata.get(key)
+ if previous:
+ fact["ambiguous"] = True
+ metadata[key] = fact
+ node["metadata"] = metadata
+
+
+def augment_cpp_constructors(root, source, nodes, edges, path):
+ """Produce bounded exact source facts; never execute or reparse the corpus."""
+ if root.has_error:
+ return
+ source_facts(root, source, nodes, path)
+ by_id = {node["id"]: node for node in nodes}
+ identities = CppIdentity(root, source, _file_stem(path))
+ classes = {}
+ syntax = list(_walk(root))
+ for item in syntax:
+ if item.type not in {"class_specifier", "struct_specifier"}:
+ continue
+ name_node = item.child_by_field_name("name")
+ name = _read_text(name_node, source) if name_node else ""
+ scope = _scope(item, source)
+ if scope is None or not _identifier(name):
+ continue
+ record = identities.record(item)
+ owner = by_id.get(record["id"]) if record else None
+ if owner is None or owner.get("_callable_class") is not True:
+ continue
+ qualified = f"{scope}::{name}" if scope else name
+ if not _identifier(qualified):
+ continue
+ if identities.preferred(record) is record:
+ fact = {"contract_version": 1, "qualified_name_b64": _encoded(qualified),
+ "scope_b64": _encoded(scope),
+ "is_definition": item.child_by_field_name("body") is not None,
+ "span": _span(item), "ambiguous": identities.ambiguous(record)}
+ _store(owner, "cpp_class", fact)
+ classes[item.id] = (owner, qualified)
+ for item in syntax:
+ if item.type not in {"declaration", "function_definition"} or item.child_by_field_name("type") is not None:
+ continue
+ declarator = item.child_by_field_name("declarator")
+ if declarator is None or declarator.type != "function_declarator":
+ continue
+ name_node = declarator.child_by_field_name("declarator")
+ name = _read_text(name_node, source) if name_node else ""
+ if not _identifier(name):
+ continue
+ scope = _scope(item, source)
+ if scope is None:
+ continue
+ parent = item.parent
+ while parent and parent.type not in {"class_specifier", "struct_specifier", "translation_unit", "namespace_definition"}:
+ parent = parent.parent
+ owner_info = classes.get(parent.id) if parent else None
+ if owner_info and name == owner_info[1].split("::")[-1]:
+ owner, qualified = owner_info
+ nid = constructor_id(owner["id"], name, item, source, qualified)
+ if nid not in by_id:
+ node = {"id": nid, "label": f".{name}()", "file_type": "code",
+ "source_file": str(path), "source_location": f"L{item.start_point.row + 1}", "_callable": True}
+ nodes.append(node)
+ by_id[nid] = node
+ edges.append({"source": owner["id"], "target": nid, "relation": "method",
+ "confidence": "EXTRACTED", "source_file": str(path),
+ "source_location": node["source_location"], "weight": 1.0})
+ role = "definition" if item.type == "function_definition" else "declaration"
+ elif not owner_info and item.type == "function_definition" and "::" in name:
+ qualified, short = name.rsplit("::", 1)
+ if qualified.split("::")[-1] != short or item.child_by_field_name("body") is None:
+ continue
+ qualified = f"{scope}::{qualified}" if scope else qualified
+ nid = qualified_function_id(_file_stem(path), item, name, source)
+ if nid not in by_id or not _identifier(qualified):
+ continue
+ role = "definition"
+ else:
+ continue
+ _store(by_id[nid], "cpp_constructor", {"contract_version": 1, "owner_b64": _encoded(qualified),
+ "scope_b64": _encoded(scope),
+ "name_b64": _encoded(name), "role": role, "span": _span(item),
+ "owner_bound": bool(owner_info and role == "definition"), **signature_fact(item, source, qualified)})
+ type_authority = ConstructorTypeAuthority(nodes)
+ for node in nodes:
+ fact = _fact(node, "cpp_constructor")
+ if fact:
+ fact["type_authorized"] = type_authority.authorized(node, fact)
+ if not fact["type_authorized"]:
+ fact["owner_bound"] = False
+
+
+def bind_cpp_constructors(nodes, edges):
+ """Public constructor seam delegates to the single corpus binding owner."""
+ from graphify.extractors.cpp_constructor_binding import bind_constructors
+ bind_constructors(nodes, edges)
+
+
+def constructor_merge_allowed(group):
+ """A header prototype cannot hide an unmatched or ambiguous implementation."""
+ facts = [_fact(node, "cpp_constructor") for node in group]
+ if any(isinstance(node.get("metadata"), dict) and "cpp_constructor" in node["metadata"] and not fact
+ for node, fact in zip(group, facts)):
+ return False
+ return not (any(fact.get("ambiguous") for fact in facts)
+ or sum(fact.get("role") == "definition" for fact in facts) > 1
+ or any(fact.get("role") == "definition" and fact.get("owner_bound") is not True for fact in facts))
+
+
+def constructor_class_authorized(node):
+ """A Qt consumer may retain the callable while rejecting its class authority."""
+ metadata = node.get("metadata")
+ if not isinstance(metadata, dict) or "cpp_constructor" not in metadata:
+ return True
+ fact = _fact(node, "cpp_constructor")
+ return bool(fact and not fact["ambiguous"] and fact.get("type_authorized") is True
+ and (fact["role"] == "declaration" or fact.get("owner_bound") is True))
diff --git a/graphify/extractors/cpp_identity.py b/graphify/extractors/cpp_identity.py
new file mode 100644
index 0000000000..316b170e40
--- /dev/null
+++ b/graphify/extractors/cpp_identity.py
@@ -0,0 +1,125 @@
+"""Exact named C++ class scopes own qualified IDs before generic deduplication.
+
+Global IDs retain the existing contract. A qualified owner adds a digest of its
+case-sensitive source spelling: ordinary ID normalization must not conflate
+namespace separators, underscores or case. No runtime or compiler is consulted.
+"""
+from __future__ import annotations
+
+import hashlib
+
+from graphify.extractors.base import _make_id, _read_text
+
+
+def _walk(root):
+ pending = [root]
+ while pending:
+ node = pending.pop()
+ yield node
+ pending.extend(reversed(node.named_children))
+
+
+def _name(value):
+ return bool(value and len(value.encode()) <= 384
+ and all(part.isidentifier() for part in value.split("::")))
+
+
+def lexical_scope(node, source):
+ """Anonymous/function-local scopes cannot establish a named native owner."""
+ parts, parent = [], node.parent
+ while parent:
+ if parent.type in {"function_definition", "lambda_expression"}:
+ return None
+ if parent.type in {"namespace_definition", "class_specifier", "struct_specifier"}:
+ name = _read_text(parent.child_by_field_name("name"), source) if parent.child_by_field_name("name") else ""
+ if not _name(name):
+ return None
+ parts.append(name)
+ parent = parent.parent
+ return "::".join(reversed(parts))
+
+
+def qualified_class_id(stem, qualified):
+ """Keep plain global IDs; salt exact qualified names before case folding."""
+ if "::" not in qualified or not _name(qualified):
+ return _make_id(stem, qualified)
+ digest = hashlib.sha256(qualified.encode()).hexdigest()
+ return _make_id(stem, "cppq", digest, qualified.rsplit("::", 1)[-1][:32])
+
+
+def qualified_function_id(stem, node, name, source):
+ """Qualified out-of-line methods use the same actual owner ID as the header."""
+ scope = lexical_scope(node, source)
+ if scope is None or "::" not in name or not _name(name):
+ return _make_id(stem, name)
+ owner, member = name.rsplit("::", 1)
+ owner = "::".join(filter(None, (scope, owner)))
+ class_id = qualified_class_id(stem, owner)
+ if (member == owner.rsplit("::", 1)[-1]
+ and node.child_by_field_name("type") is None):
+ from graphify.extractors.cpp_constructor_signature import constructor_id
+ return constructor_id(class_id, member, node, source, owner)
+ return _make_id(class_id, member)
+
+
+class CppIdentity:
+ """One immutable AST index; forward facts cannot hide a complete body."""
+
+ def __init__(self, root, source, stem):
+ self.source, self.stem = source, stem
+ self.records, self.groups = {}, {}
+ for node in _walk(root):
+ if node.type not in {"class_specifier", "struct_specifier"}:
+ continue
+ name_node = node.child_by_field_name("name")
+ name = _read_text(name_node, source) if name_node else ""
+ scope = lexical_scope(node, source)
+ qualified = "::".join(filter(None, (scope, name))) if scope is not None else ""
+ if scope is None or not _name(qualified):
+ continue
+ record = {"syntax": node, "name": name, "scope": scope,
+ "qualified": qualified, "id": qualified_class_id(stem, qualified),
+ "is_definition": node.child_by_field_name("body") is not None}
+ self.records[node.id] = record
+ self.groups.setdefault(qualified, []).append(record)
+
+ def record(self, node):
+ return self.records.get(node.id)
+
+ def constructor(self, node, name, parent_id):
+ """Inline constructors use the same exact owner/signature as prototypes."""
+ parent = node.parent
+ while parent and parent.type not in {"class_specifier", "struct_specifier", "translation_unit"}:
+ parent = parent.parent
+ record = self.record(parent) if parent else None
+ if (not record or record["id"] != parent_id or name != record["name"]
+ or node.child_by_field_name("type") is not None):
+ return _make_id(parent_id, name)
+ from graphify.extractors.cpp_constructor_signature import constructor_id
+ return constructor_id(parent_id, name, node, self.source, record["qualified"])
+
+ def preferred(self, record):
+ group = self.groups[record["qualified"]]
+ complete = [item for item in group if item["is_definition"]]
+ return (complete or group)[0]
+
+ def ambiguous(self, record):
+ return sum(item["is_definition"] for item in self.groups[record["qualified"]]) > 1
+
+ def reference(self, name, node):
+ """Resolve exact in-file class scopes without borrowing a basename."""
+ if not isinstance(name, str) or not _name(name.removeprefix("::")):
+ return ""
+ scope = lexical_scope(node, self.source)
+ if scope is None:
+ return ""
+ choices = [""] if name.startswith("::") else [
+ "::".join(scope.split("::")[:end]) for end in range(len(scope.split("::")), -1, -1)]
+ raw = name.removeprefix("::")
+ for prefix in choices:
+ qualified = "::".join(filter(None, (prefix, raw)))
+ visible = [record for record in self.groups.get(qualified, ())
+ if record["syntax"].start_byte <= node.start_byte]
+ if visible:
+ return visible[0]["id"]
+ return ""
diff --git a/graphify/extractors/cpp_member_identity.py b/graphify/extractors/cpp_member_identity.py
new file mode 100644
index 0000000000..4f5b3ca862
--- /dev/null
+++ b/graphify/extractors/cpp_member_identity.py
@@ -0,0 +1,192 @@
+"""Source-proven ordinary C++ member joins, including literal using namespaces.
+
+Qualified source spelling, singleton header signature and accepted class ownership
+must agree before changing an implementation ID. A basename alone never binds it.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+import hashlib
+import re
+
+from graphify.extractors.base import _file_stem, _make_id, _read_text
+from graphify.extractors.cpp_class_proof import class_fact
+from graphify.extractors.cpp_constructors import _decoded, _encoded, _identifier, _signature, _span, _store, _walk
+from graphify.extractors.cpp_identity import CppIdentity, lexical_scope, qualified_function_id
+
+_HEADERS = {".h", ".hh", ".hpp", ".hxx"}
+_IMPL = {".cpp", ".cc", ".cxx", ".c++"}
+
+
+def _member_signature(declarator, source):
+ parameter_signature = _signature(declarator, source)
+ if not parameter_signature:
+ return ""
+ qualifiers = [_read_text(child, source) for child in declarator.children
+ if child.type in {"type_qualifier", "ref_qualifier"}]
+ return hashlib.sha256("\0".join([parameter_signature, *qualifiers]).encode()).hexdigest()
+
+
+def _visible_usings(root, source, scope, position):
+ values = []
+ for node in _walk(root):
+ if node.type != "using_declaration" or node.start_byte >= position:
+ continue
+ literal = _read_text(node, source).strip()
+ prefix = "using namespace "
+ target = literal[len(prefix):].removesuffix(";").strip() if literal.startswith(prefix) else ""
+ local = lexical_scope(node, source)
+ parent, conditional = node.parent, False
+ while parent:
+ conditional |= parent.type.startswith("preproc_")
+ parent = parent.parent
+ if (not conditional and local is not None and _identifier(target)
+ and (not local or scope == local or scope.startswith(local + "::"))):
+ values.append(_encoded(target))
+ unique = sorted(set(values))
+ # Discarding an overflowed alternative could turn real ambiguity into a
+ # false singleton owner. Keep the member fact, but give no join authority.
+ return unique if len(unique) <= 50 else None
+
+
+def augment_cpp_members(root, source, nodes, path):
+ """Stamp bounded original AST facts before corpus canonicalization."""
+ if root.has_error:
+ return
+ identities = CppIdentity(root, source, _file_stem(path))
+ by_id = {node["id"]: node for node in nodes}
+ for item in _walk(root):
+ if item.type != "function_declarator":
+ continue
+ syntax = item.parent
+ while syntax and syntax.type not in {"field_declaration", "function_definition"}:
+ syntax = syntax.parent
+ if syntax is None or syntax.child_by_field_name("type") is None:
+ continue
+ name_node = item.child_by_field_name("declarator")
+ name = _read_text(name_node, source) if name_node else ""
+ signature, scope = _member_signature(item, source), lexical_scope(syntax, source)
+ if not _identifier(name) or not signature or scope is None:
+ continue
+ parent = syntax.parent
+ while parent and parent.type not in {"class_specifier", "struct_specifier", "translation_unit", "namespace_definition"}:
+ parent = parent.parent
+ record = identities.record(parent) if parent else None
+ if record:
+ owner, member, role = record["qualified"], name, "declaration"
+ nid = _make_id(record["id"], name)
+ elif syntax.type == "function_definition" and "::" in name:
+ raw_owner, member = name.rsplit("::", 1)
+ owner = "::".join(filter(None, (scope, raw_owner)))
+ role = "definition"
+ nid = qualified_function_id(_file_stem(path), syntax, name, source)
+ else:
+ continue
+ if nid not in by_id or not _identifier(owner) or not _identifier(member):
+ continue
+ usings = _visible_usings(root, source, scope, syntax.start_byte) if role == "definition" else []
+ _store(by_id[nid], "cpp_member", {
+ "contract_version": 1, "owner_b64": _encoded(owner), "name_b64": _encoded(name),
+ "scope_b64": _encoded(scope), "member_b64": _encoded(member), "signature": signature,
+ "role": role, "span": _span(syntax), "ambiguous": usings is None,
+ "using_b64": usings or [], "using_status": "unsupported" if usings is None else "resolved",
+ })
+
+
+def member_fact(node):
+ value = node.get("metadata", {}).get("cpp_member")
+ if (not isinstance(value, dict) or type(value.get("contract_version")) is not int
+ or value["contract_version"] != 1 or type(value.get("ambiguous")) is not bool
+ or value.get("role") not in {"declaration", "definition"}
+ or node.get("_callable") is not True or node.get("_callable_class") is True
+ or not re.fullmatch(r"[0-9a-f]{64}", str(value.get("signature", "")))):
+ return {}
+ name, owner, member = (_decoded(value.get(key)) for key in ("name_b64", "owner_b64", "member_b64"))
+ scope = "" if value.get("scope_b64") == "" else _decoded(value.get("scope_b64"))
+ span = value.get("span", {})
+ if (not name or not owner or not member or not isinstance(value.get("scope_b64"), str)
+ or (value["scope_b64"] and not scope)
+ or str(node.get("label", "")).removesuffix("()").removeprefix(".") != name
+ or name.rsplit("::", 1)[-1] != member or not isinstance(span, dict)
+ or any(type(span.get(key)) is not int or span[key] < 0 for key in
+ ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column"))
+ or span["end_byte"] <= span["start_byte"] or span["end_row"] < span["start_row"]
+ or (span["end_row"] == span["start_row"]
+ and span["end_column"] - span["start_column"] != span["end_byte"] - span["start_byte"])
+ or node.get("source_location") != f"L{span['start_row'] + 1}"
+ or not node.get("source_file")):
+ return {}
+ expected = scope if value["role"] == "declaration" else "::".join(filter(None, (scope, name.rsplit("::", 1)[0])))
+ usings = value.get("using_b64")
+ if (expected != owner or not isinstance(usings, list) or len(usings) > 50
+ or any(not _decoded(item) for item in usings)):
+ return {}
+ return value
+
+
+def _siblings(header, implementation):
+ left, right = Path(str(header)), Path(str(implementation))
+ return left.suffix.lower() in _HEADERS and right.suffix.lower() in _IMPL and left.with_suffix("") == right.with_suffix("")
+
+
+def canonicalize_cpp_members(nodes, edges, raw_calls=()):
+ """Bind only unique complete source owners and exact extracted prototypes."""
+ owners, prototypes = {}, {}
+ for node in nodes:
+ fact = class_fact(node)
+ if fact and fact["is_definition"] and not fact["ambiguous"]:
+ owners.setdefault(_decoded(fact["qualified_name_b64"]), []).append(node)
+ fact = member_fact(node)
+ if fact and fact["role"] == "declaration" and not fact["ambiguous"]:
+ prototypes.setdefault((_decoded(fact["owner_b64"]), _decoded(fact["member_b64"]), fact["signature"]), []).append(node)
+ remap = {}
+ for node in nodes:
+ fact = member_fact(node)
+ if not fact or fact["role"] != "definition" or fact["ambiguous"]:
+ continue
+ # Original file containment proves this is an accepted definition site;
+ # copying a plausible member fact onto an arbitrary callable is unsafe.
+ file_id = _make_id(str(node["source_file"]))
+ file_nodes = [item for item in nodes if item["id"] == file_id
+ and item.get("source_file") == node["source_file"]
+ and item.get("source_location") == "L1"
+ and not item.get("_callable") and not item.get("_callable_class")]
+ if len(file_nodes) != 1 or not any(
+ edge.get("source") == file_id and edge.get("target") == node["id"]
+ and edge.get("relation") == "contains" and edge.get("confidence") == "EXTRACTED"
+ and edge.get("source_file") == node["source_file"]
+ and edge.get("source_location") == node["source_location"] for edge in edges):
+ continue
+ raw_owner = _decoded(fact["owner_b64"])
+ names = {raw_owner} | {_decoded(item) + "::" + raw_owner for item in fact["using_b64"]}
+ candidates = [(name, owner) for name in names for owner in owners.get(name, ())
+ if _siblings(owner.get("source_file"), node.get("source_file"))]
+ if len(candidates) != 1:
+ continue
+ name, owner = candidates[0]
+ found = prototypes.get((name, _decoded(fact["member_b64"]), fact["signature"]), ())
+ found = [target for target in found if target.get("source_file") == owner.get("source_file")
+ and any(edge.get("source") == owner["id"] and edge.get("target") == target["id"]
+ and edge.get("relation") == "method" and edge.get("confidence") == "EXTRACTED"
+ and edge.get("source_file") == owner.get("source_file")
+ and edge.get("source_location") == target.get("source_location") for edge in edges)]
+ if len(found) != 1:
+ continue
+ fact["bound_owner_b64"] = _encoded(name)
+ remap[node["id"]] = found[0]["id"]
+ node["id"] = found[0]["id"]
+ for edge in edges:
+ for key in ("source", "target"):
+ edge[key] = remap.get(edge.get(key), edge.get(key))
+ for call in raw_calls:
+ call["source"] = remap.get(call.get("source"), call.get("source"))
+
+
+def member_merge_allowed(group):
+ facts = [member_fact(node) for node in group]
+ if not any("cpp_member" in node.get("metadata", {}) for node in group):
+ return True
+ if any(not fact or fact["ambiguous"] for fact in facts):
+ return False
+ return (len({fact["signature"] for fact in facts}) == 1
+ and len({_decoded(fact.get("bound_owner_b64", fact["owner_b64"])) for fact in facts}) == 1)
diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py
index 3db8e7b09f..501c0be3f2 100644
--- a/graphify/extractors/engine.py
+++ b/graphify/extractors/engine.py
@@ -2010,6 +2010,14 @@ def _get_cpp_func_name(node, source: bytes) -> str | None:
decl = node.child_by_field_name("declarator")
if decl:
return _get_cpp_func_name(decl, source)
+ if node.type == "reference_declarator":
+ # tree-sitter-cpp places a reference-return function directly beneath
+ # this wrapper without a declarator field. Only a sole actual function
+ # child can supply the name; unrelated or competing children stay opaque.
+ functions = [child for child in node.children if child.type == "function_declarator"]
+ if len(functions) == 1:
+ return _get_cpp_func_name(functions[0], source)
+ return None
for child in node.children:
if child.type == "identifier":
return _read_text(child, source)
@@ -4188,6 +4196,10 @@ def _extract_generic(
stem = _file_stem(path)
str_path = str(path)
+ cpp_identity = None
+ if config.ts_module == "tree_sitter_cpp":
+ from graphify.extractors.cpp_identity import CppIdentity
+ cpp_identity = CppIdentity(root, source, stem)
# Names bound by an import of a module outside the corpus. Module-scoped, so it
# is computed once per file and consulted from every scope — see
# `_js_external_import_names`.
@@ -4338,7 +4350,13 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
edge["metadata"] = sanitize_metadata(metadata)
edges.append(edge)
- def ensure_named_node(name: str, line: int) -> str:
+ def ensure_named_node(name: str, line: int, syntax=None) -> str:
+ if cpp_identity is not None and syntax is not None:
+ accepted_class = cpp_identity.reference(name, syntax)
+ if accepted_class:
+ # A known later body will materialize during the AST walk;
+ # adding a stub now would hide it through seen_ids.
+ return accepted_class
nid = _make_id(stem, ".".join(namespace_stack), name)
if nid in seen_ids:
return nid
@@ -4459,6 +4477,9 @@ def walk(node, parent_class_nid: str | None = None) -> None:
ruby_segments = class_name.split("::")
class_name = "::".join(ruby_namespace + ruby_segments)
class_nid = _make_id(stem, ".".join(namespace_stack), class_name)
+ cpp_record = cpp_identity.record(node) if cpp_identity is not None else None
+ if cpp_record:
+ class_nid = cpp_record["id"]
line = node.start_point[0] + 1
metadata = None
ruby_reopened = (
@@ -4498,6 +4519,12 @@ def walk(node, parent_class_nid: str | None = None) -> None:
ruby_body, source
):
metadata["ruby_lookup_unsafe"] = True
+ if cpp_record and cpp_identity is not None and cpp_identity.preferred(cpp_record) is cpp_record:
+ existing_class = next((item for item in nodes if item["id"] == class_nid), None)
+ if existing_class is not None:
+ # Preserve one declaration identity while making the exact
+ # complete body authoritative over an earlier forward.
+ existing_class["source_location"] = f"L{line}"
add_node(class_nid, class_name, line, metadata=metadata)
if config.ts_module == "tree_sitter_ruby" and metadata:
# Reopened declarations collapse onto the same file-local id;
@@ -5130,11 +5157,9 @@ def scala_base_name(type_node) -> str | None:
if sub.type == "type_identifier":
base = _read_text(sub, source)
elif sub.type == "qualified_identifier":
- # Use the unqualified tail so "std::vector" matches
- # a "vector" node id if one exists in the graph;
- # fall back to the full qualified text otherwise.
- tail = sub.child_by_field_name("name")
- base = _read_text(tail, source) if tail else _read_text(sub, source)
+ # Explicit qualification cannot borrow an unrelated
+ # local class that merely has the same basename.
+ base = _read_text(sub, source)
elif sub.type == "template_type":
tname = sub.child_by_field_name("name")
base = _read_text(tname, source) if tname else _read_text(sub, source)
@@ -5148,7 +5173,7 @@ def scala_base_name(type_node) -> str | None:
continue
if not base:
continue
- base_nid = ensure_named_node(base, line)
+ base_nid = ensure_named_node(base, line, sub)
add_edge(class_nid, base_nid, "inherits", line)
# Emit a generic_arg reference for each type argument on the
# base (Base -> Car references Dep). _cpp_collect_type_refs
@@ -5159,7 +5184,7 @@ def scala_base_name(type_node) -> str | None:
if arg.is_named:
_cpp_collect_type_refs(arg, source, True, arg_refs)
for ref_name, _role in arg_refs:
- target_nid = ensure_named_node(ref_name, line)
+ target_nid = ensure_named_node(ref_name, line, arg)
if target_nid != class_nid:
add_edge(class_nid, target_nid, "references",
line, context="generic_arg")
@@ -5640,7 +5665,7 @@ def scala_base_name(type_node) -> str | None:
_cpp_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
- target_nid = ensure_named_node(ref_name, line)
+ target_nid = ensure_named_node(ref_name, line, type_node)
if target_nid != parent_class_nid:
add_edge(parent_class_nid, target_nid, "references",
line, context=ctx)
@@ -5770,6 +5795,10 @@ def scala_base_name(type_node) -> str | None:
ruby_method_kind = "ambiguous"
if parent_class_nid:
func_nid = _make_id(parent_class_nid, sanitized_name)
+ # C++ overload identity belongs to the original constructor
+ # signature before generic deduplication loses occurrence spans.
+ if cpp_identity is not None:
+ func_nid = cpp_identity.constructor(node, sanitized_name, parent_class_nid)
if config.ts_module == "tree_sitter_python":
func_nid = _python_underscore_salted_nid(
func_nid, sanitized_name, python_underscore_groups
@@ -5803,6 +5832,9 @@ def scala_base_name(type_node) -> str | None:
add_edge(parent_class_nid, func_nid, "method", line)
else:
func_nid = _make_id(stem, sanitized_name)
+ if cpp_identity is not None:
+ from graphify.extractors.cpp_identity import qualified_function_id
+ func_nid = qualified_function_id(stem, node, sanitized_name, source)
if config.ts_module == "tree_sitter_python":
func_nid = _python_underscore_salted_nid(
func_nid, sanitized_name, python_underscore_groups
@@ -6087,7 +6119,7 @@ def scala_base_name(type_node) -> str | None:
collect(return_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
- target_nid = ensure_named_node(ref_name, line)
+ target_nid = ensure_named_node(ref_name, line, return_node)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
# function_declarator may be wrapped in pointer/reference declarators
@@ -7953,6 +7985,13 @@ def _scan_js_module_dispatch(n) -> None:
result["ts_type_table"] = {"path": str_path, "table": type_table}
elif config.ts_module == "tree_sitter_cpp":
result["cpp_type_table"] = {"path": str_path, "table": type_table}
+ # Constructor prototypes use C++ `declaration`, unlike ordinary member fields.
+ # Retain their exact source proof before corpus canonicalization owns the join.
+ if config.ts_module == "tree_sitter_cpp":
+ from graphify.extractors.cpp_constructors import augment_cpp_constructors
+ augment_cpp_constructors(root, source, nodes, clean_edges, path)
+ from graphify.extractors.cpp_member_identity import augment_cpp_members
+ augment_cpp_members(root, source, nodes, path)
return result
def _python_decorator_name(deco_node, source: bytes) -> str | None:
diff --git a/graphify/extractors/qml.py b/graphify/extractors/qml.py
new file mode 100644
index 0000000000..cb83914701
--- /dev/null
+++ b/graphify/extractors/qml.py
@@ -0,0 +1,34 @@
+"""Dedicated Qt 6 QML extractor with safe failures and explicit scan roots."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extractors.qml_ast import QmlInputError, failure, parse_source
+from graphify.extractors.qml_declarations import Declarations
+from graphify.extractors.qml_facts import FactBuilder
+from graphify.extractors.qml_expressions import collect_relationships
+
+
+def extract_qml(path: Path, *, root: Path | None = None) -> dict:
+ try:
+ if root is not None:
+ try:
+ path.resolve().relative_to(root.resolve())
+ except ValueError as exc:
+ raise QmlInputError("QML_ROOT", "QML source is outside the explicit scan root") from exc
+ source, program, empty = parse_source(path)
+ facts = FactBuilder(path, root, source)
+ declarations = Declarations(facts)
+ declarations.extract(program)
+ collect_relationships(declarations)
+ result = {"nodes": facts.nodes, "edges": facts.edges, "diagnostics": []}
+ if empty:
+ result["diagnostics"].append({"code": "QML_EMPTY", "severity": "info",
+ "source_file": facts.relative_file,
+ "owner": "qml", "message": "Empty editor file has no declarations",
+ "recovery": "Add a component to enable semantic analysis."})
+ return result
+ except (QmlInputError, ValueError) as exc:
+ return failure(path, root, exc)
+ except Exception:
+ return failure(path, root, QmlInputError("QML_ANALYSIS_FAILED", "QML source analysis failed; correct the analyzer and retry"))
diff --git a/graphify/extractors/qml_ast.py b/graphify/extractors/qml_ast.py
new file mode 100644
index 0000000000..30f1f985b7
--- /dev/null
+++ b/graphify/extractors/qml_ast.py
@@ -0,0 +1,73 @@
+"""Lazy offline parser loading and bounded QML input validation."""
+from __future__ import annotations
+
+from pathlib import Path
+from graphify.extractors.qml_source_identity import relative_qml_source
+
+
+class QmlInputError(Exception):
+ def __init__(self, code: str, message: str):
+ self.code = code
+ super().__init__(message)
+
+
+def load_parser():
+ try:
+ from tree_sitter_language_pack import get_parser
+ except ImportError as exc:
+ raise QmlInputError("QML_PARSER_MISSING", "QML parser not installed; install graphifyy[qml]") from exc
+ try:
+ return get_parser("qmljs")
+ except Exception as exc:
+ raise QmlInputError("QML_PARSER_LOAD", "QML parser failed to load; reinstall the pinned qml extra") from exc
+
+
+def parse_source(path: Path):
+ try:
+ if path.stat().st_size > 5_000_000:
+ raise QmlInputError("QML_LIMIT", "QML input exceeds 5 MB")
+ with path.open("rb") as stream:
+ source = stream.read(5_000_001)
+ source.decode("utf-8")
+ except (OSError, UnicodeError) as exc:
+ raise QmlInputError("QML_READ", "Cannot read UTF-8 QML source") from exc
+ if len(source) > 5_000_000:
+ raise QmlInputError("QML_LIMIT", "QML input exceeds 5 MB")
+ try:
+ tree = load_parser().parse(source)
+ except QmlInputError:
+ raise
+ except Exception as exc:
+ raise QmlInputError("QML_PARSER_LOAD", "QML parser failed to load or parse source; reinstall the pinned extra") from exc
+ # Empty editor files are explicit facts, not executable QML components.
+ if not source.strip():
+ return source, tree.root_node, True
+ if tree.root_node.has_error:
+ raise QmlInputError("QML_SYNTAX", "QML syntax is incomplete or invalid; fix source before publication")
+ if any(child.type not in {"comment", "ui_pragma", "ui_import", "ui_object_definition"}
+ for child in tree.root_node.named_children):
+ raise QmlInputError("QML_UNSUPPORTED", "Unsupported top-level QML syntax in this profile")
+ pending = [(tree.root_node, 0)]
+ count = 0
+ while pending:
+ node, depth = pending.pop()
+ count += 1
+ if count > 100_000 or depth > 256:
+ raise QmlInputError("QML_LIMIT", "QML AST exceeds the supported size/depth bound")
+ pending.extend((child, depth + 1) for child in node.named_children)
+ return source, tree.root_node, False
+
+
+def failure(path: Path, root: Path | None, error: Exception) -> dict:
+ try:
+ # Diagnostics borrow the admitted lexical label without creating facts
+ # or target authority. A failed identity lookup must not rethrow here.
+ name = relative_qml_source(path, root)
+ except (OSError, RuntimeError, ValueError):
+ name = "" if getattr(error, "code", "") == "SOURCE_INPUT_IDENTITY_FAILED" else path.name
+ code = getattr(error, "code", "QML_ROOT" if str(error).startswith("QML_ROOT:") else "QML_LIMIT")
+ diagnostic = {"code": code, "severity": "error", "source_file": name,
+ "owner": "qml", "message": str(error),
+ "recovery": "Correct source or install the qml extra, then retry; prior graph is preserved."}
+ return {"nodes": [], "edges": [], "error": str(error),
+ "diagnostics": [diagnostic], "qml_failures": [{"code": code, "source_file": name}]}
diff --git a/graphify/extractors/qml_cmake.py b/graphify/extractors/qml_cmake.py
new file mode 100644
index 0000000000..556976b54b
--- /dev/null
+++ b/graphify/extractors/qml_cmake.py
@@ -0,0 +1,119 @@
+"""Qt 6 literal qt_add_qml_module records; never execute CMake or expand variables."""
+from __future__ import annotations
+
+import hashlib
+import re
+from pathlib import Path
+
+from graphify.extractors.qml_cmake_syntax import commands
+from graphify.extractors.qml_project_read import ProjectFacts, extract_project, failure
+
+_SINGLE = {"URI", "VERSION", "RESOURCE_PREFIX", "OUTPUT_DIRECTORY", "TYPEINFO",
+ "PLUGIN_TARGET", "CLASS_NAME", "NAMESPACE"}
+_LIST = {"QML_FILES", "SOURCES", "RESOURCES", "IMPORTS", "DEPENDENCIES", "OPTIONAL_IMPORTS",
+ "DEFAULT_IMPORTS", "IMPORT_PATH", "PAST_MAJOR_VERSIONS"}
+_FLAGS = {"STATIC", "SHARED", "NO_PLUGIN", "NO_PLUGIN_OPTIONAL", "NO_CREATE_PLUGIN_TARGET",
+ "NO_GENERATE_PLUGIN_SOURCE", "NO_GENERATE_QMLTYPES", "NO_GENERATE_QMLDIR",
+ "NO_GENERATE_EXTRA_QMLDIRS", "NO_LINT", "NO_CACHEGEN", "NO_RESOURCE_TARGET_PATH",
+ "NO_IMPORT_SCAN", "DESIGNER_SUPPORTED"}
+_KEYS = _SINGLE | _LIST | _FLAGS
+
+
+def version(value: str, *, optional_minor=False):
+ match = re.fullmatch(r"(\d{1,6})(?:\.(\d{1,6}))?", value)
+ if not match or (not optional_minor and match[2] is None):
+ return None
+ return int(match[1]), int(match[2]) if match[2] is not None else None
+
+
+def uri(value: str) -> bool:
+ return bool(value) and all(part.isidentifier() for part in value.split("."))
+
+
+def _sections(tokens):
+ sections, active = {}, None
+ for token in tokens:
+ if token.value in _KEYS:
+ if token.value in sections:
+ return None
+ active = token.value
+ sections[active] = []
+ elif active is None or active in _FLAGS:
+ return None
+ else:
+ sections[active].append(token)
+ if any(len(sections[key]) != 1 for key in sections if key in _SINGLE):
+ return None
+ return sections
+
+
+def _module(facts, command):
+ start, end = facts.byte_offsets[command.start], facts.byte_offsets[command.end]
+ tokens = command.tokens
+ if command.scoped or any(token.dynamic for token in tokens):
+ facts.reject("conditional_or_expanded_qml_module", start, end)
+ return
+ sections = _sections(tokens[1:]) if tokens else None
+ if not sections or "URI" not in sections:
+ facts.reject("unsupported_qml_module_arguments", start, end)
+ return
+ name = sections["URI"][0].value
+ ver = version(sections["VERSION"][0].value) if "VERSION" in sections else (None, None)
+ if not uri(name) or ver is None:
+ facts.reject("invalid_module_uri_or_version", start, end)
+ return
+ target = tokens[0].value
+ key = hashlib.sha256(repr((facts.file, target, name)).encode()).hexdigest()
+ scalar = {key.lower(): values[0].value for key, values in sections.items() if key in _SINGLE}
+ module = facts.add("qt_module", (target, name), name, start, end,
+ uri=name, major=ver[0], minor=ver[1], target_name=target, module_key=key,
+ build_system="cmake", generated=False,
+ resource_prefix=scalar.get("resource_prefix"),
+ output_directory=scalar.get("output_directory"),
+ no_resource_target_path="NO_RESOURCE_TARGET_PATH" in sections)
+ for group, kind in (("QML_FILES", "qml"), ("SOURCES", "cpp"), ("RESOURCES", "resource")):
+ for token in sections.get(group, []):
+ facts.add_chars("qt_source", (key, kind, token.value), token.value, token.start, token.end,
+ owner=module, module_key=key, value=token.value, source_kind=kind)
+ for group in ("IMPORTS", "DEPENDENCIES", "OPTIONAL_IMPORTS", "DEFAULT_IMPORTS"):
+ if any(token.value == "TARGET" for token in sections.get(group, [])):
+ facts.reject("target_import_requires_policy_and_target_resolution", start, end)
+ continue
+ for token in sections.get(group, []):
+ parts = token.value.split("/")
+ dep_ver = version(parts[1], optional_minor=True) if len(parts) == 2 else (None, None)
+ auto = len(parts) == 2 and parts[1] == "auto"
+ if len(parts) > 2 or not uri(parts[0]) or (dep_ver is None and not auto):
+ facts.reject("unsupported_module_import", facts.byte_offsets[token.start], facts.byte_offsets[token.end])
+ continue
+ facts.add_chars("qt_module_import", (key, group, token.value), parts[0], token.start, token.end,
+ owner=module, module_key=key, uri=parts[0], major=dep_ver[0] if dep_ver else None,
+ minor=dep_ver[1] if dep_ver else None, auto=auto,
+ visibility="import" if group == "IMPORTS" else group.lower())
+ for token in sections.get("IMPORT_PATH", []):
+ facts.add_chars("qt_import_path", (key, token.value), token.value, token.start, token.end,
+ owner=module, module_key=key, value=token.value)
+ for option in ("PAST_MAJOR_VERSIONS",):
+ if option in sections:
+ facts.reject("past_major_versions_not_materialized", start, end)
+
+
+def parse_cmake(source: bytes | str, *, relative_file="CMakeLists.txt") -> dict:
+ """Supported declarations are literal, flat and source-local, with exact spans."""
+ try:
+ facts = ProjectFacts(source, relative_file, "cmake")
+ for command in commands(facts.text):
+ if command.name in {"qt_add_qml_module", "qt6_add_qml_module"}:
+ _module(facts, command)
+ elif command.name in {"qt_target_qml_sources", "target_sources",
+ "set_source_files_properties", "set_property"}:
+ # These can change the module's source/version/resource metadata.
+ facts.reject("source_mutation_outside_literal_subset", facts.byte_offsets[command.start],
+ facts.byte_offsets[command.end])
+ return facts.result()
+ except (UnicodeError, ValueError) as exc:
+ return failure(relative_file, "cmake", exc)
+
+
+def extract_cmake(path: Path, *, root: Path | None = None) -> dict:
+ return extract_project(path, root, "cmake", parse_cmake)
diff --git a/graphify/extractors/qml_cmake_syntax.py b/graphify/extractors/qml_cmake_syntax.py
new file mode 100644
index 0000000000..8d3d0ebe09
--- /dev/null
+++ b/graphify/extractors/qml_cmake_syntax.py
@@ -0,0 +1,103 @@
+"""Literal CMake tokenization with source positions and unevaluated scope barriers."""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+from graphify.extractors.qml_project_read import MetadataError
+
+
+@dataclass(frozen=True)
+class Token:
+ value: str
+ start: int
+ end: int
+ dynamic: bool = False
+
+
+@dataclass(frozen=True)
+class Command:
+ name: str
+ tokens: tuple[Token, ...]
+ start: int
+ end: int
+ scoped: bool
+
+
+def _bracket(text, offset):
+ match = re.match(r"\[(=*)\[", text[offset:])
+ if not match:
+ return None
+ close = "]" + match[1] + "]"
+ start = offset + len(match[0])
+ end = text.find(close, start)
+ if end < 0:
+ raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_bracket")
+ return start, end, end + len(close)
+
+
+def commands(text: str):
+ """Yield flat commands; declarations inside control/function scopes stay scoped."""
+ pos, scopes = 0, []
+ while pos < len(text):
+ if text[pos].isspace() or (pos == 0 and text[pos] == "\ufeff"):
+ pos += 1
+ continue
+ if text[pos] == "#":
+ block = _bracket(text, pos + 1)
+ pos = block[2] if block else text.find("\n", pos)
+ if pos < 0:
+ return
+ continue
+ match = re.match(r"([A-Za-z_][A-Za-z_0-9]*)\s*\(", text[pos:])
+ if not match:
+ raise MetadataError("QML_CMAKE_SYNTAX", "unsupported_command_syntax")
+ name, start = match[1].lower(), pos
+ pos += len(match[0])
+ tokens = []
+ while pos < len(text) and text[pos] != ")":
+ if text[pos].isspace():
+ pos += 1
+ continue
+ if text[pos] == "#":
+ block = _bracket(text, pos + 1)
+ pos = block[2] if block else text.find("\n", pos)
+ if pos < 0:
+ raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_command")
+ continue
+ token_start = pos
+ block = _bracket(text, pos)
+ if block:
+ value, pos = text[block[0]:block[1]], block[2]
+ elif text[pos] == '"':
+ pos += 1
+ while pos < len(text) and text[pos] != '"':
+ if text[pos] == "\\":
+ pos += 1
+ pos += 1
+ if pos >= len(text):
+ raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_quote")
+ value = text[token_start + 1:pos]
+ pos += 1
+ else:
+ while pos < len(text) and not text[pos].isspace() and text[pos] != ")":
+ pos += 1
+ value = text[token_start:pos]
+ tokens.append(Token(value, token_start, pos,
+ any(char in value for char in "$;\\()")))
+ if len(tokens) > 10_000:
+ raise MetadataError("QML_PROJECT_LIMIT", "cmake_token_limit")
+ if pos >= len(text):
+ raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_command")
+ pos += 1
+ if name in {"endif", "endforeach", "endwhile", "endfunction", "endmacro", "endblock"}:
+ expected = name.removeprefix("end")
+ if not scopes or scopes.pop() != expected:
+ raise MetadataError("QML_CMAKE_SYNTAX", "unbalanced_scope")
+ yield Command(name, tuple(tokens), start, pos, bool(scopes))
+ if name in {"if", "foreach", "while", "function", "macro", "block"}:
+ scopes.append(name)
+ if len(scopes) > 64:
+ raise MetadataError("QML_PROJECT_LIMIT", "cmake_scope_limit")
+ if scopes:
+ raise MetadataError("QML_CMAKE_SYNTAX", "unclosed_scope")
diff --git a/graphify/extractors/qml_declarations.py b/graphify/extractors/qml_declarations.py
new file mode 100644
index 0000000000..0232ee31a6
--- /dev/null
+++ b/graphify/extractors/qml_declarations.py
@@ -0,0 +1,157 @@
+"""Lexical QML declaration scopes; grouped properties are not instances."""
+from __future__ import annotations
+
+from graphify.extractors.qml_facts import FactBuilder, encode_metadata, field, make_scope_key, qml_metadata, span, text
+from graphify.extractors.qml_literals import literal_fields
+
+
+class Declarations:
+ def __init__(self, facts: FactBuilder):
+ self.facts = facts
+ self.source = facts.source
+ self.objects: list[tuple] = []
+ self.members: list[tuple] = []
+
+ def extract(self, program):
+ facts = self.facts
+ file = facts.add("file", facts.path.name, program, empty=not bool(self.source.strip()))
+ singleton = any(child.type == "ui_pragma" and field(child, "name", self.source) == "Singleton"
+ for child in program.named_children)
+ for ordinal, syntax in enumerate(program.named_children):
+ if syntax.type == "ui_import":
+ value = field(syntax, "source", self.source)
+ quoted = value[:1] in ("'", '"')
+ version = syntax.child_by_field_name("version")
+ major = field(version, "major", self.source) if version else ""
+ minor = field(version, "minor", self.source) if version else ""
+ facts.add("import", value.strip("\"'"), syntax, file,
+ semantic_key=f"import:{ordinal}:{value}",
+ import_kind="script" if quoted and value.strip("\"'").endswith((".js", ".mjs")) else "directory" if quoted else "module",
+ value=value.strip("\"'"), qualifier=field(syntax, "alias", self.source) or None,
+ major=int(major) if major else None, minor=int(minor) if minor else None)
+ elif syntax.type == "ui_object_definition":
+ name = facts.path.name.removesuffix(".ui.qml").removesuffix(".qml")
+ component_key = make_scope_key(facts.relative_file, name)
+ component = facts.add("component", name, syntax, file,
+ component_key=component_key, type_name=field(syntax, "type_name", self.source),
+ singleton=singleton)
+ self.object(syntax, component, component_key, "", 0)
+ self._construction_mutations()
+ return file
+
+ def object(self, syntax, owner, component_key, parent_scope, ordinal, context_key=""):
+ facts = self.facts
+ type_name = field(syntax, "type_name", self.source)
+ initializer = syntax.child_by_field_name("initializer")
+ object_id = ""
+ for child in initializer.named_children if initializer else []:
+ if child.type == "ui_binding" and field(child, "name", self.source) == "id":
+ value = child.child_by_field_name("value")
+ if value and value.named_children and value.named_children[0].type == "identifier":
+ object_id = text(value.named_children[0], self.source)
+ group = bool(type_name) and type_name.split(".")[0][:1].islower()
+ kind = "property_group" if group else "object"
+ slot = make_scope_key(facts.relative_file, context_key) if context_key else ""
+ anchor = f"{component_key}:{parent_scope}:{slot}:{kind}:{object_id or type_name + ':' + str(ordinal)}"
+ scope = make_scope_key(facts.relative_file, anchor)
+ node = facts.add(kind, type_name if group else object_id or type_name, syntax, owner,
+ semantic_key=anchor, component_key=component_key,
+ object_scope_key=scope, parent_scope_key=parent_scope,
+ construction_parent_scope_key=parent_scope if not group else "",
+ construction_parent_kind=("property" if qml_metadata(owner).get("kind") == "property" else
+ "binding" if context_key.startswith("binding:") else "default") if parent_scope else "root",
+ type_name=type_name, object_id=object_id)
+ self.objects.append((syntax, node))
+ # Component templates encapsulate their body IDs. Runtime creation
+ # context may flow inward; lexical proximity does not prove that context.
+ boundary = type_name.rsplit(".", 1)[-1] == "Component"
+ child_component = make_scope_key(facts.relative_file, f"{component_key}:body:{scope}") if boundary else component_key
+ child_parent = "" if boundary else scope
+ for index, child in enumerate(initializer.named_children if initializer else []):
+ if child.type == "ui_object_definition":
+ self.object(child, node, child_component, child_parent, index)
+ elif child.type == "ui_binding":
+ value = child.child_by_field_name("value")
+ name = field(child, "name", self.source)
+ if value and value.type in {"ui_object_definition", "ui_object_array"}:
+ template = name.split(".")[-1] in {"delegate", "sourceComponent"}
+ binding_component = make_scope_key(facts.relative_file, f"{component_key}:template:{scope}:{name}") if template else child_component
+ self.object_value(value, node, binding_component, "" if template else child_parent, f"binding:{name}")
+ elif child.type == "ui_inline_component":
+ name = field(child, "name", self.source)
+ nested_key = make_scope_key(facts.relative_file, f"{component_key}:inline:{name}")
+ inline = facts.add("inline_component", name, child, node,
+ semantic_key=f"{component_key}:inline:{name}", component_key=nested_key,
+ enclosing_component_key=component_key, parent_scope_key=scope)
+ nested = child.child_by_field_name("component")
+ if nested:
+ self.object(nested, inline, nested_key, "", 0)
+ elif child.type in ("ui_property", "ui_signal", "function_declaration", "enum_declaration"):
+ self.member(child, node, component_key, scope)
+
+ def member(self, syntax, owner, component_key, scope):
+ facts = self.facts
+ kind = {"ui_property": "property", "ui_signal": "signal",
+ "function_declaration": "function", "enum_declaration": "enum"}[syntax.type]
+ name = field(syntax, "name", self.source)
+ parameters = syntax.child_by_field_name("parameters")
+ parameter_names = ([field(parameter, "name", self.source) for parameter in parameters.named_children]
+ if kind == "signal" and parameters else [])
+ if len(parameter_names) > 50:
+ # The graph metadata sanitizer caps lists at 50. Reject instead of
+ # losing an authoritative implicit signal binder during persistence.
+ raise ValueError("QML_LIMIT: signal parameter count exceeds supported 50")
+ modifiers = [text(c, self.source) for c in syntax.named_children if c.type == "ui_property_modifier"]
+ node = facts.add(kind, name, syntax, owner, semantic_key=f"{scope}:{kind}:{name}",
+ component_key=component_key, object_scope_key=scope,
+ parent_scope_key=scope, raw_type=field(syntax, "type", self.source),
+ modifiers=modifiers, signature=text(parameters, self.source),
+ return_type=field(syntax, "return_type", self.source), parameter_names=parameter_names,
+ **(literal_fields(syntax.child_by_field_name("value"), self.source) if kind == "property" else {}))
+ self.members.append((syntax, node))
+ value = syntax.child_by_field_name("value")
+ if value and value.type in {"ui_object_definition", "ui_object_array"}:
+ template = field(syntax, "type", self.source).rsplit(".", 1)[-1] == "Component"
+ nested_component = make_scope_key(facts.relative_file, f"{component_key}:property:{node['id']}") if template else component_key
+ self.object_value(value, node, nested_component, "" if template else scope, node["id"])
+
+ def object_value(self, value, owner, component, scope, slot):
+ objects = value.named_children if value.type == "ui_object_array" else [value]
+ for ordinal, syntax in enumerate(objects):
+ if syntax.type == "ui_object_definition":
+ self.object(syntax, owner, component, scope, ordinal, context_key=slot)
+
+ def _construction_mutations(self):
+ """Creation ownership is separate from visual parent and runtime parenting.
+
+ Qt's non-widget object creator supplies a parent for nested creations,
+ including property values. It does not instantiate Component templates.
+ Explicit parent bindings/writes make that bounded tree uncertain; retain
+ their original evidence without evaluating a binding or JavaScript.
+ """
+ mutations = {}
+ for syntax, node in self.objects:
+ component = qml_metadata(node)["component_key"]
+ initializer = syntax.child_by_field_name("initializer")
+ pending = list(initializer.named_children) if initializer else []
+ while pending:
+ child = pending.pop()
+ if child.type in {"ui_object_definition", "ui_inline_component"}:
+ continue
+ target = child.child_by_field_name("left") or child.child_by_field_name("argument")
+ changed = child.type == "ui_binding" and field(child, "name", self.source).split(".")[-1] == "parent"
+ if child.type in {"assignment_expression", "augmented_assignment_expression", "update_expression"} and target:
+ changed = (target.type == "identifier" and text(target, self.source) == "parent" or
+ target.type == "member_expression" and field(target, "property", self.source) == "parent" or
+ target.type == "subscript_expression")
+ if changed:
+ evidence = mutations.setdefault(component, [])
+ evidence.append(span(child))
+ if len(evidence) > 50:
+ raise ValueError("QML_LIMIT: construction mutation evidence exceeds supported 50")
+ pending.extend(child.named_children)
+ for _, node in self.objects:
+ md = qml_metadata(node)
+ if md["component_key"] in mutations:
+ node["metadata"]["qml"] = encode_metadata({**md, "construction_parent_dynamic": True,
+ "construction_parent_mutation_spans": mutations[md["component_key"]]})
diff --git a/graphify/extractors/qml_expressions.py b/graphify/extractors/qml_expressions.py
new file mode 100644
index 0000000000..ff1a1398d6
--- /dev/null
+++ b/graphify/extractors/qml_expressions.py
@@ -0,0 +1,228 @@
+"""Source-owned QML bindings, subscriptions and JS use sites; never raw_calls."""
+from __future__ import annotations
+
+import hashlib
+
+from graphify.extractors.qml_facts import field, qml_metadata
+from graphify.extractors.qml_literals import literal_fields
+from graphify.extractors.qml_js_scopes import FUNCTIONS, bound_names, hoisted_names, local_declarations, qualified_name, reassigned_names
+
+
+class ExpressionCollector:
+ """Every occurrence has its own owner/span so simple graph edges cannot collide."""
+
+ def __init__(self, facts):
+ self.facts, self.source = facts, facts.source
+ self.functions = {}
+
+ def add(self, kind, syntax, owner, name, **values):
+ md = qml_metadata(owner)
+ key = hashlib.sha256(f'{owner["id"]}:{kind}:{syntax.start_byte}:{syntax.end_byte}'.encode()).hexdigest()
+ node = self.facts.add(kind, name, syntax, owner, semantic_key=key,
+ component_key=md.get("component_key", ""),
+ object_scope_key=md.get("object_scope_key", ""),
+ owner_id=owner["id"], script_file=md.get("script_file", ""), **values)
+ # Generic global-label resolution must not mistake an occurrence/local
+ # function for a shared JavaScript definition. This scoped resolver owns it.
+ node["label"] = f"QML site: {kind} {name}"
+ return node
+
+ def function(self, syntax, owner, name):
+ key = syntax.start_byte, owner["id"]
+ if key not in self.functions:
+ self.functions[key] = self.add("js_function", syntax, owner, name)
+ return self.functions[key]
+
+ def function_body(self, syntax, owner, environment=None):
+ body = syntax.child_by_field_name("body")
+ env = dict(environment or {})
+ env.update({name: "" for name in bound_names(syntax.child_by_field_name("parameters")
+ or syntax.child_by_field_name("parameter"), self.source)})
+ env.update({name: "" for name in hoisted_names(body, self.source)})
+ name = field(syntax, "name", self.source)
+ if name:
+ env[name] = owner["id"]
+ self.scan(body, owner, env)
+
+ def scan(self, syntax, owner, environment=None):
+ if syntax is None:
+ return
+ env = dict(environment or {})
+ kind = syntax.type
+ if kind == "statement_block":
+ for name, function in local_declarations(syntax, self.source):
+ env[name] = self.function(function, owner, name)["id"] if function else ""
+ for name in reassigned_names(syntax, self.source):
+ if name in env:
+ env[name] = ""
+ if kind in {"for_statement", "for_in_statement"}:
+ for child in syntax.named_children:
+ if child.type == "lexical_declaration":
+ for declaration in child.named_children:
+ env.update({name: "" for name in bound_names(declaration.child_by_field_name("name"), self.source)})
+ if kind == "for_in_statement":
+ if syntax.child_by_field_name("kind") is not None:
+ env.update({name: "" for name in bound_names(syntax.child_by_field_name("left"), self.source)})
+ self.scan(syntax.child_by_field_name("right"), owner, env)
+ self.scan(syntax.child_by_field_name("body"), owner, env)
+ return
+ if kind in FUNCTIONS:
+ name = field(syntax, "name", self.source) or "anonymous"
+ function = self.function(syntax, owner, name)
+ self.function_body(syntax, function, env)
+ return
+ if kind in {"variable_declarator", "required_parameter", "optional_parameter"}:
+ self.scan(syntax.child_by_field_name("value"), owner, env)
+ return
+ if kind == "catch_clause":
+ env.update({name: "" for name in bound_names(syntax.child_by_field_name("parameter"), self.source)})
+ if kind == "call_expression":
+ callee = syntax.child_by_field_name("function")
+ # A literal signal.connect callback is source AST provenance. The
+ # context resolver never re-reads QML or evaluates an argument.
+ callback = {}
+ reference = qualified_name(callee, self.source) or ""
+ arguments = syntax.child_by_field_name("arguments")
+ args = [item for item in arguments.named_children if item.type != "comment"] if arguments else []
+ if reference.endswith(".connect"):
+ name = qualified_name(args[0], self.source) if len(args) == 1 else None
+ first = (name or "").split(".")[0]
+ callback = {"callback_reference": name or "", "callback_argument_count": len(args),
+ "callback_lexical_shadowed": first in env,
+ "callback_lexical_target_id": env.get(first, "") if name == first else ""}
+ self.use("call", callee, syntax, owner, env, **callback)
+ if qualified_name(callee, self.source) is None:
+ self.scan(callee, owner, env)
+ for argument in (syntax.child_by_field_name("arguments").named_children
+ if syntax.child_by_field_name("arguments") else []):
+ self.scan(argument, owner, env)
+ return
+ if kind in {"member_expression", "subscript_expression"}:
+ self.use("read", syntax, syntax, owner, env)
+ if qualified_name(syntax.child_by_field_name("object"), self.source) is None:
+ self.scan(syntax.child_by_field_name("object"), owner, env)
+ if kind == "subscript_expression":
+ self.scan(syntax.child_by_field_name("index"), owner, env)
+ return
+ if kind in {"identifier", "shorthand_property_identifier"}:
+ self.use("read", syntax, syntax, owner, env)
+ return
+ if kind in {"assignment_expression", "augmented_assignment_expression"}:
+ left = syntax.child_by_field_name("left")
+ if kind == "augmented_assignment_expression":
+ self.scan(left, owner, env)
+ elif left and left.type in {"member_expression", "subscript_expression"}:
+ # Computing a write address reads its receiver/index; it does
+ # not read the property value being overwritten.
+ self.scan(left.child_by_field_name("object"), owner, env)
+ self.scan(left.child_by_field_name("index"), owner, env)
+ self.scan(syntax.child_by_field_name("right"), owner, env)
+ return
+ if kind in {"comment", "string", "type_annotation", "type_identifier", "ui_object_definition", "ui_object_initializer"}:
+ return
+ for child in syntax.named_children:
+ self.scan(child, owner, env)
+
+ def use(self, kind, target, syntax, owner, env, **fields):
+ reference = qualified_name(target, self.source)
+ first = (reference or "").split(".")[0]
+ self.add(kind, syntax, owner, reference or "dynamic", reference=reference or "",
+ lexical_names=sorted(env)[:50], lexical_shadowed=first in env,
+ lexical_target_id=env.get(first, "") if reference == first else "",
+ status="dynamic" if reference is None else "pending",
+ reason="computed_or_runtime_target" if reference is None else "", **fields)
+
+
+def _connection_target(syntax, source):
+ initializer = syntax.child_by_field_name("initializer")
+ for child in initializer.named_children if initializer else []:
+ if child.type == "ui_binding" and field(child, "name", source) == "target":
+ value = child.child_by_field_name("value")
+ value = value.named_children[0] if value and value.type == "expression_statement" else value
+ return qualified_name(value, source) or "", True
+ return "", False
+
+
+def _signal_parameters(declarations, owner, signal_name, connection_target):
+ """Source-declared implicit signal parameters participate in JS shadowing."""
+ md = qml_metadata(owner)
+ scope = md.get("object_scope_key")
+ if connection_target:
+ objects = [node for _, node in declarations.objects if qml_metadata(node).get("object_id") == connection_target
+ and qml_metadata(node).get("component_key") == md.get("component_key")]
+ scope = qml_metadata(objects[0]).get("object_scope_key") if len(objects) == 1 else None
+ for syntax, node in declarations.members:
+ member = qml_metadata(node)
+ if member.get("kind") == "signal" and member.get("raw_name") == signal_name and member.get("object_scope_key") == scope:
+ parameters = syntax.child_by_field_name("parameters")
+ return {field(p, "name", declarations.source): "" for p in parameters.named_children} if parameters else {}
+ return {}
+
+
+def collect_relationships(declarations) -> None:
+ """Collect facts only; project lookup is a separate run-scoped resolver."""
+ collector = ExpressionCollector(declarations.facts)
+ members = {(qml_metadata(node).get("object_scope_key"), qml_metadata(node)["raw_name"]): node
+ for _, node in declarations.members}
+ objects = {node["id"]: syntax for syntax, node in declarations.objects}
+ owners = {qml_metadata(node).get("object_scope_key"): node for _, node in declarations.objects}
+ for syntax, owner in declarations.objects:
+ initializer = syntax.child_by_field_name("initializer")
+ for child in initializer.named_children if initializer else []:
+ if child.type != "ui_binding" or field(child, "name", collector.source) == "id":
+ continue
+ name, value = field(child, "name", collector.source), child.child_by_field_name("value")
+ if value is None or value.type in {"ui_object_definition", "ui_object_array"}:
+ continue
+ short = name.split(".")[-1]
+ if short.startswith("on") and len(short) > 2 and short[2].isupper():
+ _handler(declarations, collector, child, owner, name, value, syntax)
+ else:
+ member = members.get((qml_metadata(owner).get("object_scope_key"), name), owner)
+ binding = collector.add("binding", child, member, name, reference=name, **literal_fields(value, collector.source))
+ collector.scan(value, binding)
+ for syntax, member in declarations.members:
+ md = qml_metadata(member)
+ owner = owners.get(md.get("object_scope_key"), member)
+ name = md["raw_name"]
+ if md["kind"] == "property":
+ value = syntax.child_by_field_name("value")
+ if value is None or value.type == "ui_object_definition":
+ continue
+ if md.get("raw_type") == "alias":
+ expression = value.named_children[0] if value.type == "expression_statement" and value.named_children else value
+ reference = qualified_name(expression, collector.source)
+ collector.add("alias", value, member, name, reference=reference or "",
+ status="pending" if reference else "dynamic", reason="" if reference else "alias_not_static")
+ else:
+ binding = collector.add("binding", value, member, name, **literal_fields(value, collector.source))
+ collector.scan(value, binding)
+ elif md["kind"] == "function":
+ if qml_metadata(owner).get("type_name") == "Connections" and name.startswith("on"):
+ _handler(declarations, collector, syntax, owner, name, syntax.child_by_field_name("body"), objects[owner["id"]], syntax)
+ else:
+ collector.function_body(syntax, member)
+
+
+def _handler(declarations, collector, syntax, owner, name, body, object_syntax, function=None):
+ short = name.split(".")[-1]
+ signal = short[2:3].lower() + short[3:]
+ connections = qml_metadata(owner).get("type_name") == "Connections"
+ target, supplied = _connection_target(object_syntax, collector.source) if connections else ("", False)
+ initializer = object_syntax.child_by_field_name("initializer")
+ legacy = any(child.type == "ui_binding" and field(child, "name", collector.source).startswith("on")
+ and field(child, "name", collector.source)[2:3].isupper()
+ for child in initializer.named_children) if initializer else False
+ # Only legacy code blocks inherit signal declaration names. Function and
+ # arrow handlers bind their own formals, which need not use those names.
+ implicit_parameters = function is None and body is not None and body.type == "statement_block"
+ handler = collector.add("handler", syntax, owner, name, reference=signal,
+ connection_target=target, connection_supplied=supplied,
+ connections=connections, attached_prefix=name[:-len(short)].rstrip("."),
+ implicit_parameters=implicit_parameters,
+ disabled_reason="connections_mixed_handler_styles" if connections and function and legacy else "")
+ env = _signal_parameters(declarations, owner, signal, target) if implicit_parameters else {}
+ if function:
+ collector.function_body(function, handler, env)
+ else:
+ collector.scan(body, handler, env)
diff --git a/graphify/extractors/qml_facts.py b/graphify/extractors/qml_facts.py
new file mode 100644
index 0000000000..4994ee7d9c
--- /dev/null
+++ b/graphify/extractors/qml_facts.py
@@ -0,0 +1,128 @@
+"""Portable, bounded QML facts and lossless semantic metadata transport."""
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+from collections.abc import Mapping
+from pathlib import Path
+
+from graphify.ids import make_id
+from graphify.extractors.qml_source_identity import SourceInputIdentityError, relative_qml_source
+
+CONTRACT_VERSION = 1
+
+
+def make_qml_id(relative_file: str, kind: str, semantic_key: str) -> str:
+ identity = json.dumps([relative_file, kind, semantic_key], ensure_ascii=False)
+ digest = hashlib.sha256(identity.encode()).hexdigest()[:16]
+ return make_id("qml", relative_file, kind, semantic_key, digest)
+
+
+def make_scope_key(relative_file: str, semantic_key: str) -> str:
+ """Fixed-width scopes avoid multiplying parent ID length with nesting."""
+ identity = json.dumps([relative_file, semantic_key], ensure_ascii=False)
+ return hashlib.sha256(identity.encode()).hexdigest()
+
+
+def span(node) -> dict:
+ return {
+ "start_byte": node.start_byte, "end_byte": node.end_byte,
+ "start_row": node.start_point.row, "start_column": node.start_point.column,
+ "end_row": node.end_point.row, "end_column": node.end_point.column,
+ }
+
+
+def text(node, source: bytes) -> str:
+ return source[node.start_byte:node.end_byte].decode("utf-8") if node else ""
+
+
+def field(node, name: str, source: bytes) -> str:
+ return text(node.child_by_field_name(name), source)
+
+
+def qml_metadata(node: dict) -> dict:
+ """Read literal lookup values after graph sanitation/JSON round trips.
+
+ Display values retain Graphify's HTML escaping. Their bounded base64 copies
+ preserve semantic punctuation without trusting HTML as a lookup encoding.
+ """
+ meta = dict(node.get("metadata", {}).get("qml", {}))
+ raw = meta.get("raw_values", {})
+ if not isinstance(raw, Mapping) or len(raw) > 50:
+ raise ValueError("QML_METADATA: invalid literal transport mapping")
+ for key, value in raw.items():
+ if not isinstance(key, str) or not isinstance(value, str) or not isinstance(meta.get(key), str):
+ raise ValueError("QML_METADATA: invalid literal transport field")
+ if len(value) > 512:
+ raise ValueError("QML_LIMIT: literal transport exceeds its encoded bound")
+ try:
+ meta[key] = base64.b64decode(value, validate=True).decode("utf-8")
+ except (ValueError, UnicodeError) as exc:
+ raise ValueError("QML_METADATA: invalid literal transport encoding") from exc
+ return meta
+
+
+def encode_metadata(values: dict) -> dict:
+ raw = {}
+ for key, value in values.items():
+ if isinstance(value, str):
+ encoded = base64.b64encode(value.encode()).decode("ascii")
+ if len(encoded) > 512:
+ raise ValueError("QML_LIMIT: semantic field exceeds transport bound")
+ raw[key] = encoded
+ return {"contract_version": CONTRACT_VERSION, **values, "raw_values": raw}
+
+
+class FactBuilder:
+ """One source owns declarations and derived sites; roots never enter IDs."""
+
+ def __init__(self, path: Path, root: Path | None, source: bytes):
+ self.path = path
+ self.source = source
+ try:
+ # Fact IDs use the admitted walked owner; physical target identity
+ # still governs containment and later resolution authority.
+ self.relative_file = relative_qml_source(path, root)
+ # QML file/component names are also identity inputs. Native short
+ # leaf spelling must not survive after its source owner is admitted.
+ self.path = path.with_name(Path(self.relative_file).name)
+ except SourceInputIdentityError:
+ raise
+ except ValueError as exc:
+ raise ValueError("QML_ROOT: source is outside the explicit scan root") from exc
+ self.nodes: list[dict] = []
+ self.edges: list[dict] = []
+ self.occurrences: dict[str, int] = {}
+
+ def add(self, kind: str, name: str, syntax_node, owner=None, **values) -> dict:
+ key = values.pop("semantic_key", name)
+ identity = make_qml_id(self.relative_file, kind, key)
+ occurrence = self.occurrences.get(identity, 0)
+ self.occurrences[identity] = occurrence + 1
+ if occurrence:
+ # Conflicting declarations retain separate evidence for ambiguity;
+ # valid named declarations keep identities independent of line spans.
+ identity = make_qml_id(self.relative_file, kind, f"{key}:duplicate:{occurrence}")
+ values["duplicate_declaration"] = True
+ meta = encode_metadata({"kind": kind, "raw_name": name,
+ "raw_type": "", "span": span(syntax_node), **values})
+ node = {"id": identity, "label": name or kind, "file_type": "code", "_origin": "ast",
+ "type": {"file": "file", "component": "class",
+ "inline_component": "class", "function": "function",
+ "property": "property"}.get(kind, "concept"),
+ "source_file": self.relative_file,
+ "source_location": f"L{syntax_node.start_point.row + 1}-L{syntax_node.end_point.row + 1}",
+ "metadata": {"qml": meta}}
+ self.nodes.append(node)
+ if owner:
+ self.edge(owner["id"] if isinstance(owner, dict) else owner,
+ identity, "contains", syntax_node, kind="declaration")
+ return node
+
+ def edge(self, source: str, target: str, relation: str, syntax_node, **values):
+ self.edges.append({"source": source, "target": target, "relation": relation,
+ "_src": source, "_tgt": target,
+ "confidence": "EXTRACTED", "source_file": self.relative_file,
+ "source_location": f"L{syntax_node.start_point.row + 1}-L{syntax_node.end_point.row + 1}",
+ "metadata": {"qml": encode_metadata({"span": span(syntax_node), **values})}})
diff --git a/graphify/extractors/qml_js_scopes.py b/graphify/extractors/qml_js_scopes.py
new file mode 100644
index 0000000000..76692b3718
--- /dev/null
+++ b/graphify/extractors/qml_js_scopes.py
@@ -0,0 +1,82 @@
+"""Lexical JavaScript bindings for QML subtrees; no value evaluation/dataflow."""
+from __future__ import annotations
+
+from graphify.extractors.qml_facts import text
+
+FUNCTIONS = {"function_declaration", "function_expression", "arrow_function", "generator_function_declaration"}
+
+
+def bound_names(node, source: bytes) -> set[str]:
+ """Collect binding positions only; object keys and type names are not locals."""
+ if node is None:
+ return set()
+ if node.type in {"identifier", "shorthand_property_identifier_pattern"}:
+ return {text(node, source)}
+ if node.type in {"required_parameter", "optional_parameter", "assignment_pattern", "pair_pattern"}:
+ child = (node.child_by_field_name("pattern") or node.child_by_field_name("left")
+ or node.child_by_field_name("value"))
+ return bound_names(child, source)
+ return set().union(*(bound_names(c, source) for c in node.named_children
+ if c.type not in {"type_annotation", "type_identifier", "property_identifier"}))
+
+
+def qualified_name(node, source: bytes) -> str | None:
+ """Accept a literal identifier/member chain; computed accesses stay dynamic."""
+ if node is None:
+ return None
+ if node.type in {"identifier", "property_identifier", "nested_identifier"}:
+ value = text(node, source)
+ return value if len(value) <= 256 else None
+ if node.type == "member_expression":
+ receiver = qualified_name(node.child_by_field_name("object"), source)
+ member = node.child_by_field_name("property")
+ if receiver and member and member.type == "property_identifier":
+ value = receiver + "." + text(member, source)
+ return value if len(value) <= 256 else None
+ return None
+
+
+def hoisted_names(body, source: bytes) -> set[str]:
+ """var binds the function; nested functions and their vars remain isolated."""
+ names, pending = set(), [body]
+ while pending:
+ node = pending.pop()
+ if node is None or node.type in FUNCTIONS:
+ continue
+ if node.type == "variable_declaration":
+ for child in node.named_children:
+ if child.type == "variable_declarator":
+ names.update(bound_names(child.child_by_field_name("name"), source))
+ if node.type == "for_in_statement" and text(node.child_by_field_name("kind"), source) == "var":
+ names.update(bound_names(node.child_by_field_name("left"), source))
+ pending.extend(node.named_children)
+ return names
+
+
+def local_declarations(block, source: bytes):
+ """Block-level let/const/function bindings shadow only their lexical block."""
+ for child in block.named_children:
+ if child.type == "function_declaration":
+ yield text(child.child_by_field_name("name"), source), child
+ elif child.type in {"lexical_declaration", "variable_declaration"}:
+ for declaration in child.named_children:
+ if declaration.type != "variable_declarator":
+ continue
+ value = declaration.child_by_field_name("value")
+ for name in bound_names(declaration.child_by_field_name("name"), source):
+ yield name, value if value and value.type in FUNCTIONS else None
+
+
+def reassigned_names(body, source: bytes) -> set[str]:
+ """A reassigned callable has runtime value/dataflow; don't reuse its initializer."""
+ names, pending = set(), [body]
+ while pending:
+ node = pending.pop()
+ if node is None or node.type in FUNCTIONS:
+ continue
+ if node.type in {"assignment_expression", "augmented_assignment_expression", "update_expression"}:
+ target = node.child_by_field_name("left") or node.child_by_field_name("argument")
+ if target and target.type == "identifier":
+ names.add(text(target, source))
+ pending.extend(node.named_children)
+ return names
diff --git a/graphify/extractors/qml_literals.py b/graphify/extractors/qml_literals.py
new file mode 100644
index 0000000000..76ecbae90a
--- /dev/null
+++ b/graphify/extractors/qml_literals.py
@@ -0,0 +1,17 @@
+"""Plain source literal evidence for static C++ objectName/property joins."""
+from __future__ import annotations
+
+import json
+
+
+def literal_fields(syntax, source):
+ if syntax and syntax.type == "expression_statement" and len(syntax.named_children) == 1:
+ syntax = syntax.named_children[0]
+ if syntax is None or syntax.type != "string":
+ return {}
+ value = source[syntax.start_byte:syntax.end_byte].decode()
+ try:
+ decoded = json.loads(value) if value.startswith('"') else value[1:-1] if value.startswith("'") and "\\" not in value else None
+ except ValueError:
+ return {}
+ return {"literal_value": decoded} if isinstance(decoded, str) and len(decoded.encode()) <= 256 else {}
diff --git a/graphify/extractors/qml_metadata.py b/graphify/extractors/qml_metadata.py
new file mode 100644
index 0000000000..30737ab62e
--- /dev/null
+++ b/graphify/extractors/qml_metadata.py
@@ -0,0 +1,181 @@
+"""Bounded, source-only qmldir reader with independently owned metadata records."""
+
+from __future__ import annotations
+
+import re
+import base64
+from pathlib import Path
+
+from graphify.qml_resolution_types import fact_edge, fact_node
+from graphify.extractors.qml_source_identity import SourceInputIdentityError, relative_qml_source
+
+MAX_METADATA_BYTES = 1_048_576
+MAX_METADATA_RECORDS = 10_000
+_VERSION = re.compile(r"^(\d{1,6})\.(\d{1,6})$")
+
+
+def _version(value: str) -> tuple[int, int] | None:
+ match = _VERSION.fullmatch(value)
+ return (int(match[1]), int(match[2])) if match else None
+
+
+def _identifier(value: str) -> bool:
+ return all(part.isidentifier() for part in value.split("."))
+
+
+def parse_qmldir(text: str, *, relative_file: str = "qmldir") -> dict:
+ """Parse supported literal directives; unsupported coverage is never success."""
+ file_node = fact_node(relative_file, "file", "qmldir", Path(relative_file).name, 1,
+ metadata_format="qmldir", span={
+ "start_byte": 0, "end_byte": len(text.encode("utf-8")),
+ "start_row": 0, "start_column": 0,
+ "end_row": text.count("\n"),
+ "end_column": len(text.rsplit("\n", 1)[-1].encode("utf-8"))})
+ nodes, edges, failures = [file_node], [], []
+ module_node = None
+ occurrences: dict[tuple, int] = {}
+ exports: set[tuple] = set()
+
+ # Each record owns its data, avoiding the export-list cap in metadata rendering.
+ def record(kind, key, label, line, **fields):
+ count = occurrences.get((kind, key), 0)
+ occurrences[kind, key] = count + 1
+ node = fact_node(relative_file, kind, (key, count), label, line,
+ span=line_spans[line], **fields)
+ nodes.append(node)
+ parent = module_node or file_node
+ edges.append(fact_edge(parent["id"], node["id"], "contains", node,
+ "qml_metadata_declaration", span=line_spans[line]))
+ return node
+
+ def fail(line, reason):
+ if len(failures) < 50:
+ failures.append({"line": line, "code": "QML-META-001", "reason": reason})
+
+ if len(text.encode("utf-8")) > MAX_METADATA_BYTES:
+ fail(1, "metadata_size_limit")
+ text = ""
+ # UTF-8 byte offsets remain attached to each admitted directive.
+ line_spans, offset = {}, 0
+ source_lines = text.splitlines(keepends=True)
+ for line, raw in enumerate(source_lines, 1):
+ body = raw.rstrip("\r\n")
+ prefix_bytes = 3 if line == 1 and body.startswith("\ufeff") else 0
+ end = offset + len(body.encode("utf-8"))
+ line_spans[line] = {"start_byte": offset + prefix_bytes, "end_byte": end,
+ "start_row": line - 1, "end_row": line - 1,
+ "start_column": prefix_bytes,
+ "end_column": len(body.encode("utf-8"))}
+ offset += len(raw.encode("utf-8"))
+ meaningful = 0
+ for line, raw in enumerate(text.splitlines(), 1):
+ if line == 1:
+ raw = raw.removeprefix("\ufeff")
+ value = raw.strip()
+ if not value or value.startswith("#"):
+ continue
+ meaningful += 1
+ if meaningful > MAX_METADATA_RECORDS:
+ fail(line, "metadata_record_limit")
+ break
+ tokens = value.split()
+ if any(len(base64.b64encode(token.encode("utf-8"))) > 512 or
+ any(ord(char) < 32 for char in token) for token in tokens):
+ fail(line, "metadata_value_limit")
+ continue
+ head = tokens[0]
+ if head == "module":
+ if len(tokens) != 2 or not _identifier(tokens[1]) or module_node or meaningful != 1:
+ fail(line, "invalid_module_declaration")
+ continue
+ module_node = record("module", tokens[1], tokens[1], line, uri=tokens[1],
+ raw_name=tokens[1])
+ continue
+ if head in {"depends", "import"}:
+ if len(tokens) not in {2, 3} or not _identifier(tokens[1]):
+ fail(line, "invalid_module_dependency")
+ continue
+ version = _version(tokens[2]) if len(tokens) == 3 else None
+ auto = len(tokens) == 3 and tokens[2] == "auto" and head == "import"
+ if len(tokens) == 3 and not version and not auto:
+ fail(line, "invalid_module_version")
+ continue
+ record("module_import" if head == "import" else "dependency", tuple(tokens),
+ tokens[1], line, uri=tokens[1], major=version[0] if version else None,
+ minor=version[1] if version else None, auto=auto)
+ continue
+ if head in {"plugin", "optional", "classname", "typeinfo", "prefer",
+ "designersupported"}:
+ directive = "plugin" if tokens[:2] == ["optional", "plugin"] else head
+ args = tokens[2:] if tokens[:2] == ["optional", "plugin"] else tokens[1:]
+ valid = ((directive == "plugin" and len(args) in {1, 2}) or
+ (directive in {"classname", "typeinfo", "prefer"} and len(args) == 1) or
+ (directive == "designersupported" and not args))
+ if not valid:
+ fail(line, "unsupported_or_malformed_directive")
+ continue
+ record(directive, tuple(tokens), directive, line, value=args[0] if args else "",
+ optional=head == "optional", path=args[1] if len(args) > 1 else "")
+ continue
+ flag = head if head in {"singleton", "internal"} else ""
+ entry = tokens[1:] if flag else tokens
+ version = _version(entry[1]) if len(entry) == 3 else None
+ valid = ((len(entry) == 2 and flag != "singleton") or
+ (len(entry) == 3 and version and flag != "internal"))
+ if not valid or not entry[0].isidentifier() or not entry[-1].endswith((".qml", ".js", ".mjs")):
+ fail(line, "unsupported_or_malformed_directive")
+ continue
+ export_key = (entry[0], version, flag == "internal")
+ if export_key in exports:
+ fail(line, "duplicate_export")
+ exports.add(export_key)
+ record("export", (entry[0], version, entry[-1], flag), entry[0], line,
+ raw_name=entry[0], target_path=entry[-1],
+ major=version[0] if version else None, minor=version[1] if version else None,
+ internal=flag == "internal", singleton=flag == "singleton",
+ export_kind="script" if entry[-1].endswith((".js", ".mjs")) else "type")
+ result = {"nodes": nodes, "edges": edges, "diagnostics": []}
+ if failures:
+ result.update(error="QML-META-001: incomplete qmldir metadata; inspect diagnostics",
+ partial=True, parse_errors=failures)
+ result["diagnostics"] = [{"code": item["code"], "severity": "error",
+ "owner": "qmldir", "source_file": relative_file,
+ "line": item["line"], "reason": item["reason"],
+ "message": "Unsupported or malformed qmldir metadata",
+ "recovery": "Correct the directive or use the documented literal subset."}
+ for item in failures]
+ return result
+
+
+def extract_qmldir(path: Path, *, root: Path | None = None) -> dict:
+ """Read one admitted file; do not follow any paths or plugins it declares."""
+ path = Path(path)
+ relative = ""
+ try:
+ # Admission stays physical; source facts and failure context retain the
+ # separately discovered lexical owner before original bytes are read.
+ relative = relative_qml_source(path, root)
+ if path.stat().st_size > MAX_METADATA_BYTES:
+ return _read_failure(relative, "metadata_size_limit")
+ with path.open("rb") as stream:
+ raw = stream.read(MAX_METADATA_BYTES + 1)
+ if len(raw) > MAX_METADATA_BYTES:
+ return _read_failure(relative, "metadata_size_limit")
+ # Keep BOM and CRLF bytes in provenance; only tokenization skips a BOM.
+ text = raw.decode("utf-8")
+ except SourceInputIdentityError as exc:
+ return _read_failure("", exc.reason, code=exc.code)
+ except (OSError, RuntimeError, UnicodeError, ValueError):
+ return _read_failure(relative, "metadata_unreadable_or_outside_root")
+ return parse_qmldir(text, relative_file=relative)
+
+
+def _read_failure(relative: str, reason: str, *, code="QML-META-002") -> dict:
+ identity_failed = code == "SOURCE_INPUT_IDENTITY_FAILED"
+ return {"nodes": [], "edges": [], "error": f"{code}: metadata read rejected",
+ "diagnostics": [{"code": code, "severity": "error", "owner": "qmldir",
+ "source_file": relative, "reason": reason,
+ "message": "Source input identity unavailable" if identity_failed else
+ "Metadata unreadable, oversized or outside the scan root",
+ "recovery": "Repair filesystem access and retry." if identity_failed else
+ "Use an accepted UTF-8 qmldir within the documented limits."}]}
diff --git a/graphify/extractors/qml_project_read.py b/graphify/extractors/qml_project_read.py
new file mode 100644
index 0000000000..61cc346a58
--- /dev/null
+++ b/graphify/extractors/qml_project_read.py
@@ -0,0 +1,131 @@
+"""Bounded original-byte Qt metadata input and source-owned fact construction."""
+from __future__ import annotations
+
+import hashlib
+import posixpath
+from bisect import bisect_left
+from pathlib import Path
+
+from graphify.qml_resolution_types import fact_edge, fact_node
+from graphify.extractors.qml_source_identity import relative_qml_source
+
+MAX_PROJECT_BYTES = 1_048_576
+MAX_PROJECT_FACTS = 10_000
+
+
+class MetadataError(ValueError):
+ def __init__(self, code: str, reason: str):
+ self.code, self.reason = code, reason
+ super().__init__(f"{code}: {reason}")
+
+
+def literal_path(source_file: str, value: str) -> str | None:
+ """Normalize a literal within the corpus without reading its target."""
+ value = value.replace("\\", "/")
+ if not value or value.startswith("/") or ":" in value or "\x00" in value:
+ return None
+ path = posixpath.normpath(posixpath.join(posixpath.dirname(source_file), value))
+ return None if path == ".." or path.startswith("../") else path
+
+
+class ProjectFacts:
+ """One accepted metadata file owns records; no mutable project context exists."""
+ def __init__(self, source: bytes | str, relative_file: str, metadata_format: str):
+ self.raw = source.encode("utf-8") if isinstance(source, str) else source
+ if len(self.raw) > MAX_PROJECT_BYTES:
+ raise MetadataError("QML_PROJECT_LIMIT", "metadata_size_limit")
+ self.text = self.raw.decode("utf-8")
+ self.file = relative_file
+ self.format = metadata_format
+ self.nodes: list[dict] = []
+ self.edges: list[dict] = []
+ self.diagnostics: list[dict] = []
+ self.counts: dict[tuple, int] = {}
+ self.newlines = [pos for pos, char in enumerate(self.raw) if char == 10]
+ self.byte_offsets = [0]
+ for char in self.text:
+ self.byte_offsets.append(self.byte_offsets[-1] + len(char.encode("utf-8")))
+ self.file_node = self.add("file", metadata_format, Path(relative_file).name,
+ 0, len(self.raw), metadata_format=metadata_format)
+
+ def span(self, start: int, end: int) -> dict:
+ def point(offset):
+ row = bisect_left(self.newlines, offset)
+ return row, offset - (self.newlines[row - 1] + 1 if row else 0)
+ sr, sc = point(start)
+ er, ec = point(end)
+ return {"start_byte": start, "end_byte": end, "start_row": sr,
+ "start_column": sc, "end_row": er, "end_column": ec}
+
+ def add(self, kind: str, key, label: str, start: int, end: int, *, owner=None,
+ **fields) -> dict:
+ if len(self.nodes) >= MAX_PROJECT_FACTS:
+ raise MetadataError("QML_PROJECT_LIMIT", "metadata_fact_limit")
+ digest = hashlib.sha256(repr((kind, key)).encode("utf-8")).hexdigest()
+ occurrence = self.counts.get((kind, digest), 0)
+ self.counts[kind, digest] = occurrence + 1
+ location = self.span(start, end)
+ node = fact_node(self.file, kind, (digest, occurrence), label,
+ location["start_row"] + 1, span=location, **fields)
+ self.nodes.append(node)
+ parent = owner or getattr(self, "file_node", None)
+ if parent:
+ self.edges.append(fact_edge(parent["id"], node["id"], "contains", node,
+ "qt_metadata_declaration", span=location))
+ return node
+
+ def add_chars(self, kind, key, label, start, end, **fields):
+ return self.add(kind, key, label, self.byte_offsets[start], self.byte_offsets[end], **fields)
+
+ def reject(self, reason: str, start=0, end=None, *, code="QML_PROJECT_UNSUPPORTED"):
+ if len(self.diagnostics) < 50:
+ self.diagnostics.append({"code": code, "reason": reason, "severity": "error",
+ "owner": self.format, "source_file": self.file,
+ "span": self.span(start, len(self.raw) if end is None else end),
+ "message": "Qt metadata coverage is incomplete",
+ "recovery": "Use the documented literal subset and retry."})
+
+ def result(self) -> dict:
+ result: dict = {"nodes": self.nodes, "edges": self.edges, "diagnostics": self.diagnostics}
+ if self.diagnostics:
+ result.update(error="QML_PROJECT_UNSUPPORTED: incomplete metadata coverage", partial=True,
+ qml_failures=[{"code": d["code"], "source_file": self.file,
+ "reason": d["reason"]} for d in self.diagnostics])
+ return result
+
+
+def failure(relative_file: str, metadata_format: str, error: Exception) -> dict:
+ code = getattr(error, "code", "QML_PROJECT_READ")
+ reason = getattr(error, "reason", "metadata_unreadable_or_outside_root")
+ identity_failed = code == "SOURCE_INPUT_IDENTITY_FAILED"
+ # Native admission failed before a source label was established. Match the
+ # other typed readers' explicit unavailable context, without a raw basename.
+ if identity_failed:
+ relative_file = ""
+ return {"nodes": [], "edges": [], "error": f"{code}: metadata rejected",
+ "diagnostics": [{"code": code, "reason": reason, "severity": "error",
+ "owner": metadata_format, "source_file": relative_file,
+ "message": "Source input identity unavailable" if identity_failed else
+ "Qt metadata rejected before source expansion",
+ "recovery": "Repair filesystem access and retry." if identity_failed else
+ "Use accepted UTF-8 metadata within documented bounds."}],
+ "qml_failures": [{"code": code, "source_file": relative_file, "reason": reason}]}
+
+
+def extract_project(path: Path, root: Path | None, metadata_format: str, parser) -> dict:
+ """Read original bytes with stat/read bounds and an explicit source-root gate."""
+ path = Path(path)
+ relative = path.name
+ try:
+ # Resolve authority before retaining a lexical source owner. Literal
+ # targets remain relative to that declaration; no new file is admitted.
+ relative = relative_qml_source(path, root)
+ if path.stat().st_size > MAX_PROJECT_BYTES:
+ raise MetadataError("QML_PROJECT_LIMIT", "metadata_size_limit")
+ with path.open("rb") as stream:
+ raw = stream.read(MAX_PROJECT_BYTES + 1)
+ if len(raw) > MAX_PROJECT_BYTES:
+ raise MetadataError("QML_PROJECT_LIMIT", "metadata_size_limit")
+ return parser(raw, relative_file=relative)
+ except (OSError, RuntimeError, UnicodeError, ValueError) as exc:
+ return failure(relative, metadata_format, exc)
diff --git a/graphify/extractors/qml_qmake.py b/graphify/extractors/qml_qmake.py
new file mode 100644
index 0000000000..b238204ba7
--- /dev/null
+++ b/graphify/extractors/qml_qmake.py
@@ -0,0 +1,75 @@
+"""Qt 6 qmake module facts with bounded current-file paths and lexical coverage."""
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+
+from graphify.extractors.qml_cmake import uri, version
+from graphify.extractors.qml_project_read import ProjectFacts, extract_project, failure
+from graphify.extractors.qml_qmake_values import assignments
+
+
+def parse_qmake(source: bytes | str, *, relative_file="project.pro") -> dict:
+ try:
+ facts = ProjectFacts(source, relative_file, "qmake")
+ accepted = assignments(facts)
+ names = accepted.get("QML_IMPORT_NAME", [])
+ if len(names) > 1 or (names and not uri(names[0].value)):
+ facts.reject("invalid_module_uri")
+ if not names or facts.diagnostics:
+ _import_paths(facts, accepted, None, None)
+ return facts.result()
+ entry = names[0]
+ name, start, end = entry.value, entry.start, entry.end
+ ver_entries = accepted.get("QML_IMPORT_VERSION", [])
+ major, minor = accepted.get("QML_IMPORT_MAJOR_VERSION", []), accepted.get("QML_IMPORT_MINOR_VERSION", [])
+ if len(ver_entries) > 1 or len(major) > 1 or len(minor) > 1:
+ facts.reject("invalid_module_version")
+ return facts.result()
+ ver = version(ver_entries[0].value) if ver_entries else version(
+ major[0].value + "." + (minor[0].value if minor else "0")) if major else None
+ if ver is None or (ver_entries and (major or minor)):
+ facts.reject("missing_or_conflicting_module_version")
+ return facts.result()
+ targets = accepted.get("TARGET", [])
+ if len(targets) > 1:
+ facts.reject("invalid_target_name")
+ return facts.result()
+ target = targets[0].value if len(targets) == 1 else Path(relative_file).stem
+ key = hashlib.sha256(repr((relative_file, target, name)).encode()).hexdigest()
+ module = facts.add_chars("qt_module", (target, name), name, start, end,
+ uri=name, major=ver[0], minor=ver[1], target_name=target,
+ module_key=key, build_system="qmake", generated=False,
+ version_origin="literal_version" if ver_entries else
+ "literal_major_minor" if minor else "base_major_registration",
+ minor_upper_bound_known=bool(ver_entries or minor),
+ resource_prefix=None, no_resource_target_path=False)
+ # Accepted lexical paths stay relative to this metadata file. Project
+ # indexing joins only already admitted source facts; no disk discovery.
+ for group, kind in (("SOURCES", "cpp"), ("HEADERS", "cpp"), ("QML_FILES", "qml"),
+ ("DISTFILES", "distribution"), ("RESOURCES", "qrc")):
+ for entry in accepted.get(group, []):
+ facts.add_chars("qt_source", (key, kind, entry.value), entry.value,
+ entry.start, entry.end, owner=module,
+ module_key=key, value=entry.value, source_kind=kind,
+ source_value=entry.source_value, path_origin=entry.path_origin)
+ # Tooling hints and build scanner roots are separate facts. Neither is
+ # an application runtime search path or an implicit analysis root.
+ _import_paths(facts, accepted, module, key)
+ return facts.result()
+ except (UnicodeError, ValueError) as exc:
+ return failure(relative_file, "qmake", exc)
+
+
+def _import_paths(facts, accepted, module, key):
+ """Ordinary .pro files may declare hints without creating a QML module."""
+ for group, visibility in (("QML_IMPORT_PATH", "tooling"), ("QMLPATHS", "build")):
+ for entry in accepted.get(group, []):
+ facts.add_chars("qt_import_path", (key, group, entry.value), entry.value,
+ entry.start, entry.end, owner=module, module_key=key,
+ value=entry.value, visibility=visibility, qmake_variable=group,
+ source_value=entry.source_value, path_origin=entry.path_origin)
+
+
+def extract_qmake(path: Path, *, root: Path | None = None) -> dict:
+ return extract_project(path, root, "qmake", parse_qmake)
diff --git a/graphify/extractors/qml_qmake_values.py b/graphify/extractors/qml_qmake_values.py
new file mode 100644
index 0000000000..60e1508a5b
--- /dev/null
+++ b/graphify/extractors/qml_qmake_values.py
@@ -0,0 +1,180 @@
+"""Bounded qmake lexical assignments with original positions and no evaluation.
+
+This reader owns statement coverage and literal path interpretation. The caller
+owns module facts; uncertain metadata cannot become module lookup authority.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+from graphify.extractors.qml_project_read import literal_path
+
+
+PATH_KEYS = {"SOURCES", "HEADERS", "RESOURCES", "DISTFILES", "QML_FILES",
+ "QML_IMPORT_PATH", "QMLPATHS"}
+INTEREST = PATH_KEYS | {"TARGET", "QML_IMPORT_NAME", "QML_IMPORT_VERSION",
+ "QML_IMPORT_MAJOR_VERSION", "QML_IMPORT_MINOR_VERSION"}
+_ASSIGNMENT = re.compile(r"([A-Za-z_][A-Za-z_0-9.]*)\s*(=|\+=|-=|\*=|~=)\s*(.*)")
+_TOKENS = re.compile(r'"[^"\r\n]*"|\x27[^\x27\r\n]*\x27|[^\s]+')
+_MAX_SCOPES = 64
+
+
+@dataclass(frozen=True)
+class QmakeValue:
+ value: str
+ source_value: str
+ start: int
+ end: int
+ path_origin: str = "literal"
+
+
+def statements(text):
+ """Join continuations with a position map into the unmodified source text."""
+ pending, positions, pos = "", [], 0
+ for raw in text.splitlines(keepends=True):
+ body = raw.rstrip("\r\n")
+ quote, length = "", len(body)
+ for index, char in enumerate(body):
+ if char in "\"'":
+ quote = "" if quote == char else char if not quote else quote
+ if char == "#" and not quote:
+ length = index
+ break
+ value = body[:length]
+ if pos == 0 and value.startswith("\ufeff"):
+ value = value[1:]
+ offset = 1
+ else:
+ offset = 0
+ continued = value.rstrip().endswith("\\")
+ if continued:
+ value = value.rstrip()[:-1]
+ pending += value
+ positions.extend(range(pos + offset, pos + offset + len(value)))
+ if continued:
+ pending += " "
+ positions.append(pos + len(body))
+ elif pending.strip():
+ yield pending, positions
+ pending, positions = "", []
+ else:
+ pending, positions = "", []
+ pos += len(raw)
+ if pending.strip():
+ yield pending, positions
+
+
+def values(text, offset, positions, key, source_file):
+ """Interpret only an exact PWD prefix; reject all other expansions and escapes."""
+ result, previous_end = [], 0
+ for token in _TOKENS.finditer(text):
+ spelling = token.group()
+ if ((result and token.start() == previous_end) or
+ spelling.count('"') % 2 or spelling.count("'") % 2 or
+ (not spelling.startswith(('"', "'")) and any(char in spelling for char in "\"'"))):
+ return None, "conditional_or_expanded_qmake_value"
+ previous_end = token.end()
+ value = spelling[1:-1] if spelling.startswith(('"', "'")) else spelling
+ source_value = value
+ origin = "literal"
+ if key in PATH_KEYS and (value == "$$PWD" or value.startswith("$$PWD/")):
+ value, origin = value[5:].lstrip("/") or ".", "current_file_pwd"
+ if any(char in value for char in "$(){};*"):
+ return None, "conditional_or_expanded_qmake_value"
+ if key in PATH_KEYS:
+ if max(len(value.encode("utf-8")), len(source_value.encode("utf-8"))) > 384:
+ return None, "qmake_path_limit"
+ if literal_path(source_file, value) is None:
+ return None, "qmake_path_outside_accepted_root"
+ start = positions[offset + token.start()]
+ end = positions[offset + token.end() - 1] + 1
+ result.append(QmakeValue(value, source_value, start, end, origin))
+ return result, None
+
+
+def assignments(facts):
+ """Retain observable lexical coverage while withholding failed module authority."""
+ accepted, scope = {}, 0
+ for statement, positions in statements(facts.text):
+ text = statement.strip()
+ start, end = positions[0], positions[-1] + 1
+
+ # Balanced scope delimiters have no branch truth. Relevance belongs to
+ # statements inside them, so an unrelated toolchain scope is harmless.
+ if text == "}":
+ scope -= 1
+ if scope < 0:
+ facts.reject("unbalanced_qmake_scope")
+ scope = 0
+ continue
+ if text in {"} else {", "}else{"}:
+ if scope == 0:
+ facts.reject("unbalanced_qmake_scope")
+ continue
+ if text.endswith("{") and not _ASSIGNMENT.fullmatch(text):
+ scope += 1
+ if scope > _MAX_SCOPES:
+ facts.reject("qmake_scope_limit")
+ return accepted
+ continue
+
+ # A log string can mention qmake assignment syntax without changing
+ # project facts. Classify known display statements before searching keys.
+ if re.fullmatch(r"(?:message|warning)\([^\r\n]*\)", text):
+ _coverage(facts, "qt_qmake_statement", "ignored", "unrelated_build_statement",
+ start, end, conditional=bool(scope))
+ continue
+
+ match = _ASSIGNMENT.fullmatch(statement.strip())
+ conditional = bool(scope)
+ if match:
+ leading = len(statement) - len(statement.lstrip())
+ else:
+ # A condition prefix remains opaque. It may contain qmake test
+ # functions; finding an assignment is never evaluating the prefix.
+ candidates = list(_ASSIGNMENT.finditer(statement))
+ match = candidates[-1] if candidates else None
+ leading, conditional = 0, True
+ if not match:
+ _coverage(facts, "qt_qmake_statement", "unresolved", "unsupported_qmake_statement",
+ start, end, conditional=bool(scope))
+ facts.reject("unsupported_qmake_statement", facts.byte_offsets[start], facts.byte_offsets[end])
+ continue
+ key, operator, value = match.groups()
+ if key == "PWD":
+ facts.reject("qmake_pwd_reassignment", facts.byte_offsets[start], facts.byte_offsets[end])
+ continue
+ if key not in INTEREST:
+ _coverage(facts, "qt_qmake_statement", "ignored", "unrelated_build_setting",
+ start, end, conditional=conditional, qmake_variable=key)
+ continue
+ if conditional:
+ _coverage(facts, "qt_qmake_statement", "unresolved", "conditional_qmake_value",
+ start, end, conditional=True, qmake_variable=key)
+ facts.reject("conditional_or_expanded_qmake_value", facts.byte_offsets[start], facts.byte_offsets[end])
+ continue
+ entries, reason = values(value, leading + match.start(3), positions, key, facts.file)
+ if entries is None or operator not in {"=", "+="}:
+ facts.reject(reason or "conditional_or_expanded_qmake_value",
+ facts.byte_offsets[start], facts.byte_offsets[end])
+ continue
+ # Individual lexical values remain inspectable even if a later unknown
+ # mutation blocks the file's module authority. They never enter lookup.
+ for entry in entries:
+ facts.add_chars("qt_qmake_assignment", (key, operator, entry.value), key,
+ entry.start, entry.end, status="observed", conditional=False,
+ reason="unconditional_literal_assignment", qmake_variable=key,
+ operator=operator, value=entry.value, source_value=entry.source_value,
+ path_origin=entry.path_origin)
+ accepted[key] = accepted.get(key, []) + entries if operator == "+=" else entries
+ if scope:
+ facts.reject("unbalanced_qmake_scope")
+ return accepted
+
+
+def _coverage(facts, kind, status, reason, start, end, **fields):
+ """Coverage records use safe categories, never unbounded source/provider text."""
+ variable = fields.get("qmake_variable", "statement")
+ facts.add_chars(kind, (variable, status, reason), variable, start, end,
+ status=status, reason=reason, **fields)
diff --git a/graphify/extractors/qml_resources.py b/graphify/extractors/qml_resources.py
new file mode 100644
index 0000000000..558d452aa0
--- /dev/null
+++ b/graphify/extractors/qml_resources.py
@@ -0,0 +1,98 @@
+"""Bounded QRC source declarations without DTD/entity expansion or target reads."""
+from __future__ import annotations
+
+import posixpath
+from pathlib import Path
+from xml.parsers import expat
+
+from graphify.extractors.qml_project_read import (
+ MetadataError, ProjectFacts, extract_project, failure, literal_path,
+)
+
+
+def resource_url(prefix: str, alias: str) -> str | None:
+ """Accept exact portable resource paths; traversal is never normalized away."""
+ prefix, alias = prefix.replace("\\", "/"), alias.replace("\\", "/")
+ if not alias or alias.startswith("/") or any(char in prefix + alias for char in ":%?#\x00"):
+ return None
+ joined = prefix.rstrip("/") + "/" + alias
+ if any(part in {".", ".."} for part in joined.split("/")):
+ return None
+ return "qrc:/" + joined.lstrip("/")
+
+
+def parse_qrc(source: bytes | str, *, relative_file="resources.qrc") -> dict:
+ try:
+ facts = ProjectFacts(source, relative_file, "qrc")
+ parser = expat.ParserCreate("UTF-8")
+ parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER)
+ stack, record = [], None
+ prefix, locale = "/", ""
+
+ def forbidden(*_args):
+ raise MetadataError("QML_QRC_ENTITY", "xml_dtd_or_entity_forbidden")
+
+ def start(name, attributes):
+ nonlocal prefix, locale, record
+ if len(stack) > 16:
+ raise MetadataError("QML_PROJECT_LIMIT", "qrc_depth_limit")
+ if name == "RCC" and not stack:
+ if set(attributes) - {"version"}:
+ facts.reject("unsupported_qrc_attribute")
+ elif name == "qresource" and stack == ["RCC"]:
+ if set(attributes) - {"prefix", "lang"}:
+ facts.reject("unsupported_qresource_attribute")
+ prefix, locale = attributes.get("prefix", "/"), attributes.get("lang", "")
+ elif name == "file" and stack == ["RCC", "qresource"]:
+ if set(attributes) - {"alias", "compress", "compression-algorithm", "threshold", "empty"}:
+ facts.reject("unsupported_qrc_file_attribute")
+ record = {"start": parser.CurrentByteIndex, "alias": attributes.get("alias"),
+ "parts": [], "empty": attributes.get("empty", "false")}
+ else:
+ facts.reject("unsupported_qrc_structure")
+ stack.append(name)
+
+ def data(value):
+ if record is not None and stack == ["RCC", "qresource", "file"]:
+ record["parts"].append(value)
+ elif value.strip():
+ facts.reject("unexpected_qrc_text")
+
+ def close(name):
+ nonlocal record
+ if name == "file" and record is not None:
+ target = "".join(record["parts"]).strip()
+ alias = record["alias"] if record["alias"] is not None else target
+ logical = resource_url(prefix, alias)
+ path = literal_path(relative_file, target)
+ end = facts.raw.find(b">", parser.CurrentByteIndex) + 1
+ if logical is None or path is None:
+ facts.reject("resource_path_escape_or_invalid_alias", record["start"], end,
+ code="QML_QRC_PATH")
+ elif record["empty"] == "true":
+ facts.reject("empty_resource_has_no_source_content", record["start"], end)
+ else:
+ facts.add("resource_alias", (logical, target, locale), logical,
+ record["start"], end, logical_url=logical,
+ target_path=path, value=target, prefix=prefix, alias=alias,
+ locale=locale, origin="qrc", generated=False)
+ record = None
+ stack.pop()
+
+ parser.StartDoctypeDeclHandler = forbidden
+ parser.EntityDeclHandler = forbidden
+ parser.ExternalEntityRefHandler = forbidden
+ parser.StartElementHandler, parser.EndElementHandler = start, close
+ parser.CharacterDataHandler = data
+ parser.Parse(facts.raw, True)
+ if not facts.raw.strip() or not facts.nodes:
+ facts.reject("missing_qrc_root")
+ return facts.result()
+ except (expat.ExpatError, UnicodeError, ValueError) as exc:
+ if isinstance(exc, expat.ExpatError):
+ exc = MetadataError("QML_QRC_SYNTAX", "invalid_resource_xml")
+ return failure(relative_file, "qrc", exc)
+
+
+def extract_qrc(path: Path, *, root: Path | None = None) -> dict:
+ return extract_project(path, root, "qrc", parse_qrc)
diff --git a/graphify/extractors/qml_script_syntax.py b/graphify/extractors/qml_script_syntax.py
new file mode 100644
index 0000000000..3fbc8783c5
--- /dev/null
+++ b/graphify/extractors/qml_script_syntax.py
@@ -0,0 +1,85 @@
+"""Bounded JS parsing with byte-preserving Qt directives; no evaluation/import IO."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from graphify.extractors.qml_ast import QmlInputError
+
+_PRAGMA = re.compile(rb'\s*\.pragma\s+library\s*(?://[^\r\n]*)?$')
+_IMPORT = re.compile(rb'\s*\.import\s+["\']([^"\'\r\n]+)["\']\s+as\s+([A-Za-z_$][\w$]*)\s*(?://[^\r\n]*)?$')
+
+
+def _next_state(line: bytes, state: str) -> str:
+ """Directives inside comments/templates/continued strings are ordinary text."""
+ position = 0
+ while position < len(line):
+ char = line[position:position + 1]
+ pair = line[position:position + 2]
+ if state == "comment":
+ if pair == b"*/":
+ state, position = "", position + 2
+ else:
+ position += 1
+ elif state:
+ if char == b"\\":
+ position += 2
+ elif char == state.encode():
+ state, position = "", position + 1
+ else:
+ position += 1
+ elif pair == b"//":
+ break
+ elif pair == b"/*":
+ state, position = "comment", position + 2
+ elif char in {b'"', b"'", b"`"}:
+ state, position = char.decode(), position + 1
+ else:
+ position += 1
+ return state
+
+
+def parse_script(path: Path):
+ """Return original bytes, a same-offset tree and literal Qt directive facts."""
+ try:
+ if path.stat().st_size > 5_000_000:
+ raise QmlInputError("QML_SCRIPT_LIMIT", "QML imported script exceeds 5 MB")
+ with path.open("rb") as stream:
+ source = stream.read(5_000_001)
+ source.decode("utf-8")
+ except (OSError, UnicodeError) as exc:
+ raise QmlInputError("QML_SCRIPT_READ", "Cannot read UTF-8 QML imported script") from exc
+ if len(source) > 5_000_000:
+ raise QmlInputError("QML_SCRIPT_LIMIT", "QML imported script exceeds 5 MB")
+ masked, directives, state, offset = [], [], "", 0
+ for row, line in enumerate(source.splitlines(keepends=True)):
+ body = line.rstrip(b"\r\n")
+ pragma, imported = (_PRAGMA.fullmatch(body), _IMPORT.fullmatch(body)) if not state else (None, None)
+ if pragma or imported:
+ directives.append({"kind": "pragma" if pragma else "import", "start_byte": offset,
+ "end_byte": offset + len(body), "row": row,
+ "value": imported[1].decode() if imported else "library",
+ "qualifier": imported[2].decode() if imported else ""})
+ masked.append(b" " * len(body) + line[len(body):])
+ else:
+ masked.append(line)
+ state = _next_state(line, state)
+ offset += len(line)
+ if path.suffix.lower() == ".mjs" and directives:
+ raise QmlInputError("QML_SCRIPT_UNSUPPORTED", "Qt directives belong to classic .js scripts, not ECMAScript modules")
+ try:
+ from tree_sitter import Language, Parser
+ import tree_sitter_javascript
+ tree = Parser(Language(tree_sitter_javascript.language())).parse(b"".join(masked))
+ except Exception as exc:
+ raise QmlInputError("QML_SCRIPT_PARSER", "Cannot load the JavaScript parser for a QML import") from exc
+ if tree.root_node.has_error:
+ raise QmlInputError("QML_SCRIPT_SYNTAX", "QML imported script syntax is incomplete or invalid")
+ pending, count = [(tree.root_node, 0)], 0
+ while pending:
+ node, depth = pending.pop()
+ count += 1
+ if count > 100_000 or depth > 256:
+ raise QmlInputError("QML_SCRIPT_LIMIT", "QML imported script exceeds AST size/depth bounds")
+ pending.extend((child, depth + 1) for child in node.named_children)
+ return source, tree.root_node, directives
diff --git a/graphify/extractors/qml_scripts.py b/graphify/extractors/qml_scripts.py
new file mode 100644
index 0000000000..d1ccf5bf58
--- /dev/null
+++ b/graphify/extractors/qml_scripts.py
@@ -0,0 +1,192 @@
+"""QML-owned overlays for already accepted JS files; ordinary JS facts stay intact."""
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+
+from graphify.extractors.qml_ast import failure
+from graphify.extractors.qml_expressions import ExpressionCollector
+from graphify.extractors.qml_facts import FactBuilder, field, qml_metadata, text
+from graphify.extractors.qml_js_scopes import FUNCTIONS, local_declarations, reassigned_names
+from graphify.extractors.qml_script_syntax import parse_script
+from graphify.qml_resolution_types import relative_reference
+
+
+def _literal(node, source):
+ value = text(node, source)
+ return value[1:-1] if value[:1] in {"'", '"'} and value[-1:] == value[:1] and "\\" not in value else ""
+
+
+def _exports(program, source):
+ """Explicit module exports control importer visibility; locals stay file-owned."""
+ exports = {}
+ for statement in program.named_children:
+ if statement.type != "export_statement":
+ continue
+ if statement.child_by_field_name("source"):
+ # Re-exports require an explicit module-export graph, not a local
+ # same-name fallback. This profile leaves them unavailable.
+ continue
+ declaration = statement.child_by_field_name("declaration")
+ if declaration:
+ if declaration.type in FUNCTIONS:
+ name = field(declaration, "name", source)
+ exports.setdefault(name, []).append("default" if text(statement, source).startswith("export default") else name)
+ else:
+ for declarator in declaration.named_children:
+ if declarator.type == "variable_declarator":
+ from graphify.extractors.qml_js_scopes import bound_names
+ for name in bound_names(declarator.child_by_field_name("name"), source):
+ exports.setdefault(name, []).append(name)
+ for clause in statement.named_children:
+ if clause.type == "export_clause":
+ for specifier in clause.named_children:
+ name = field(specifier, "name", source)
+ alias = field(specifier, "alias", source) or name
+ exports.setdefault(name, []).append(alias)
+ return exports
+
+
+def _script_imports(facts, program, directives, owner):
+ for directive in directives:
+ if directive["kind"] != "import":
+ continue
+ node = facts.add("script_import", directive["qualifier"], program, owner,
+ semantic_key=str(directive["start_byte"]), script_file=facts.relative_file,
+ value=directive["value"], qualifier=directive["qualifier"], import_kind="namespace")
+ node["label"] = "QML script import: " + directive["qualifier"]
+ node["source_location"] = f'L{directive["row"] + 1}'
+ # The directive is masked only for parsing; preserve the original source span.
+ md = qml_metadata(node)
+ md["span"] = {"start_byte": directive["start_byte"], "end_byte": directive["end_byte"],
+ "start_row": directive["row"], "start_column": 0,
+ "end_row": directive["row"], "end_column": directive["end_byte"] - directive["start_byte"]}
+ from graphify.extractors.qml_facts import encode_metadata
+ node["metadata"]["qml"] = encode_metadata({k: v for k, v in md.items() if k != "raw_values"})
+ for statement in program.named_children:
+ if statement.type != "import_statement":
+ continue
+ value = _literal(statement.child_by_field_name("source"), facts.source)
+ for clause in statement.named_children:
+ if clause.type != "import_clause":
+ continue
+ for binding in clause.named_children:
+ if binding.type == "namespace_import":
+ names = [(text(binding.named_children[-1], facts.source), "namespace", "")]
+ elif binding.type == "identifier":
+ names = [(text(binding, facts.source), "named", "default")]
+ elif binding.type == "named_imports":
+ names = [(field(s, "alias", facts.source) or field(s, "name", facts.source),
+ "named", field(s, "name", facts.source)) for s in binding.named_children]
+ else:
+ names = []
+ for name, kind, exported in names:
+ node = facts.add("script_import", name, binding, owner,
+ semantic_key=f"{binding.start_byte}:{name}", script_file=facts.relative_file,
+ value=value, qualifier=name, import_kind=kind, exported_name=exported)
+ node["label"] = "QML script import: " + name
+
+
+def _overlay(path: Path, root: Path):
+ source, program, directives = parse_script(path)
+ facts = FactBuilder(path, root, source)
+ library = any(item["kind"] == "pragma" for item in directives)
+ owner = facts.add("script_file", path.name, program, script_file=facts.relative_file,
+ pragma_library=library, script_profile="module" if path.suffix.lower() == ".mjs" else "classic")
+ owner["label"] = "QML script: " + path.name
+ _script_imports(facts, program, directives, owner)
+ exported, declarations, reassigned = _exports(program, source), [], reassigned_names(program, source)
+ for statement in program.named_children:
+ declaration = statement.child_by_field_name("declaration") if statement.type == "export_statement" else statement
+ if declaration is None:
+ continue
+ if declaration.type in FUNCTIONS:
+ declarations.append((field(declaration, "name", source), declaration))
+ elif declaration.type in {"lexical_declaration", "variable_declaration"}:
+ for variable in declaration.named_children:
+ if variable.type != "variable_declarator":
+ continue
+ function = variable.child_by_field_name("value")
+ if function is not None and function.type in FUNCTIONS:
+ from graphify.extractors.qml_js_scopes import bound_names
+ declarations.extend((name, function) for name in bound_names(variable.child_by_field_name("name"), source))
+ collector, environment = ExpressionCollector(facts), {}
+ for name, syntax in declarations:
+ key = hashlib.sha256(f'{name}:{syntax.start_byte}'.encode()).hexdigest()
+ node = facts.add("qml_script_function", name, syntax, owner, semantic_key=key,
+ script_file=facts.relative_file, static_callable=name not in reassigned,
+ export_names=exported.get(name, []) if path.suffix.lower() == ".mjs" else [name])
+ node["label"] = "QML script: " + (name or "default")
+ environment[name] = node["id"] if name not in reassigned else ""
+ collector.functions[syntax.start_byte, owner["id"]] = node
+ # Top-level values also shadow names; no QML document's IDs leak into a script.
+ for name, _ in local_declarations(program, source):
+ environment.setdefault(name, "")
+ for (_, syntax), node in zip(declarations, [n for n in facts.nodes if qml_metadata(n).get("kind") == "qml_script_function"]):
+ collector.function_body(syntax, node, environment)
+ for statement in program.named_children:
+ if statement.type not in {"import_statement", "export_statement"} | FUNCTIONS:
+ collector.scan(statement, owner, environment)
+ return {"nodes": facts.nodes, "edges": facts.edges}
+
+
+def collect_qml_scripts(paths, per_file, *, root: Path) -> None:
+ """Only enrich accepted imports, including accepted literal script dependencies.
+
+ Never discover paths, load network resources or alter shared JS extraction.
+ Failures attach to the owning accepted result so publication safety can reject.
+ """
+ if not any(qml_metadata(node).get("kind") in {"import", "export"}
+ for result in per_file if result for node in result.get("nodes", [])):
+ return
+ accepted = {}
+ anchor = Path(root).resolve()
+ for path, result in zip(paths, per_file):
+ if not result or Path(path).suffix.lower() not in {".js", ".mjs"}:
+ continue
+ try:
+ relative = Path(path).resolve().relative_to(anchor).as_posix()
+ except ValueError:
+ continue
+ accepted[relative] = Path(path), result
+ wanted = set()
+ for path, result in zip(paths, per_file):
+ if not result:
+ continue
+ try:
+ source = Path(path).resolve().relative_to(anchor).as_posix()
+ except ValueError:
+ continue
+ for node in result.get("nodes", []):
+ md = qml_metadata(node)
+ if md.get("kind") == "import" and md.get("import_kind") == "script":
+ target = relative_reference(source, md.get("value") or "")
+ if target in accepted:
+ wanted.add(target)
+ elif md.get("kind") == "export" and md.get("export_kind") == "script":
+ target = relative_reference(source, md.get("target_path") or "")
+ if target in accepted:
+ wanted.add(target)
+ visited = set()
+ while wanted - visited:
+ name = sorted(wanted - visited)[0]
+ visited.add(name)
+ path, result = accepted[name]
+ if len(visited) > 256:
+ result.setdefault("qml_failures", []).append({"code": "QML_SCRIPT_LIMIT", "source_file": name})
+ return
+ try:
+ overlay = _overlay(path, Path(root))
+ except Exception as exc:
+ failed = failure(path, Path(root), exc)
+ result.setdefault("qml_failures", []).extend(failed["qml_failures"])
+ result.setdefault("diagnostics", []).extend(failed["diagnostics"])
+ continue
+ result.setdefault("nodes", []).extend(overlay["nodes"])
+ result.setdefault("edges", []).extend(overlay["edges"])
+ for node in overlay["nodes"]:
+ md = qml_metadata(node)
+ if md.get("kind") == "script_import":
+ target = relative_reference(name, md.get("value") or "")
+ if target in accepted:
+ wanted.add(target)
diff --git a/graphify/extractors/qml_source_identity.py b/graphify/extractors/qml_source_identity.py
new file mode 100644
index 0000000000..255e98168f
--- /dev/null
+++ b/graphify/extractors/qml_source_identity.py
@@ -0,0 +1,43 @@
+"""Relative Qt fact ownership after physical admission, without target authority.
+
+Readers retain source bytes, limits and publication ownership. This stateless
+boundary reuses native spelling admission and walked-name selection; it reads
+identity metadata only and stores no root, index or cache between calls.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from graphify.source_identity import normalize_input_source, walked_relative_source
+
+
+class SourceInputIdentityError(ValueError):
+ """Preserve the native admission category without carrying backend bodies."""
+ code = "SOURCE_INPUT_IDENTITY_FAILED"
+ reason = "native_source_spelling_unavailable"
+
+ def __init__(self):
+ super().__init__("SOURCE_INPUT_IDENTITY_FAILED: native source spelling unavailable; "
+ "repair filesystem access and retry")
+
+
+def relative_qml_source(path: Path, root: Path | None) -> str:
+ """Keep discovered lexical owners while rejecting foreign physical targets."""
+ try:
+ admitted = normalize_input_source(Path(path), root)
+ except ValueError:
+ raise SourceInputIdentityError() from None
+ # POSIX link/.. traversal follows the physical parent. Capture that input
+ # before lexical abspath normalization can describe a different file.
+ physical = admitted.resolve()
+ written = physical if ".." in admitted.parts else Path(os.path.abspath(admitted))
+ anchor = Path(root).resolve() if root is not None else physical.parent
+
+ def realpath(value: str, _cwd: str) -> Path:
+ # Explicit inputs are absolute; the shared naming boundary owns neither
+ # this reader's filesystem calls nor a second memoization lifetime.
+ return Path(value).resolve()
+
+ return walked_relative_source(written, anchor, physical,
+ realpath=realpath, cwd=os.getcwd()).as_posix()
diff --git a/graphify/extractors/qml_types.py b/graphify/extractors/qml_types.py
new file mode 100644
index 0000000000..a1f88e06c7
--- /dev/null
+++ b/graphify/extractors/qml_types.py
@@ -0,0 +1,179 @@
+"""Literal generated QML type descriptions, retained separately from source APIs."""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+from graphify.extractors.qml_ast import QmlInputError, load_parser
+from graphify.extractors.qml_cmake import uri, version
+from graphify.extractors.qml_facts import field, text
+from graphify.extractors.qml_project_read import MetadataError, ProjectFacts, extract_project, failure
+
+_COMPONENT_FIELDS = {"name", "prototype", "file", "exports", "exportMetaObjectRevisions",
+ "isSingleton", "isCreatable", "isComposite", "defaultProperty", "attachedType",
+ "extension", "accessSemantics", "interfaces", "isStructured", "isValueType",
+ "isSequence", "sequenceValueType", "isRoot", "isCustomParser", "deferredNames",
+ "immediateNames", "isScript", "isExtendedTypeNamespace", "extensionIsJavaScript",
+ "hasCustomParser", "enforcesScopedEnums", "valueType", "aliases",
+ "extensionIsNamespace", "isJavaScriptBuiltin"}
+_MEMBER_FIELDS = {"name", "type", "isReadonly", "isList", "isPointer", "revision", "isRequired",
+ "isFinal", "index", "read", "write", "notify", "bindable", "isConstructor",
+ "isJavaScriptFunction", "isCloned", "isConst", "isMethodConstant", "values",
+ "alias", "isFlag", "isScoped", "isEnum", "isUnderlyingType", "underlyingType",
+ "privateClass", "isConstant", "reset"}
+
+
+def _literal(node, raw):
+ if node.type == "expression_statement" and len(node.named_children) == 1:
+ return _literal(node.named_children[0], raw)
+ if node.type == "array":
+ return [_literal(child, raw) for child in node.named_children if child.type != "comment"]
+ if node.type == "object":
+ values = {}
+ for pair in node.named_children:
+ if pair.type == "comment":
+ continue
+ key, value = pair.child_by_field_name("key"), pair.child_by_field_name("value")
+ if pair.type != "pair" or key is None or key.type != "string" or value is None:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "nonliteral_enum_entry")
+ name = _literal(key, raw)
+ if name in values:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "duplicate_enum_entry")
+ values[name] = _literal(value, raw)
+ return values
+ if node.type in {"string", "number", "true", "false", "null", "unary_expression"}:
+ try:
+ return json.loads(text(node, raw))
+ except ValueError as exc:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "non_json_literal") from exc
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "computed_type_description_value")
+
+
+def _bindings(syntax, facts, allowed):
+ initializer = syntax.child_by_field_name("initializer")
+ values, nested = {}, []
+ for child in initializer.named_children if initializer else []:
+ if child.type == "comment":
+ continue
+ if child.type == "ui_object_definition":
+ nested.append(child)
+ continue
+ if child.type != "ui_binding":
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "unsupported_type_description_syntax")
+ name = field(child, "name", facts.raw)
+ if name not in allowed or name in values:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "unknown_or_duplicate_description_field")
+ value = child.child_by_field_name("value")
+ if value is None:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_description_value")
+ values[name] = _literal(value, facts.raw)
+ literal = values[name]
+ if name.startswith(("is", "has", "enforces")) and not isinstance(literal, bool):
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "invalid_boolean_description_field")
+ if name in {"name", "type", "prototype", "file", "notify", "read", "write", "bindable"} and not isinstance(literal, str):
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "invalid_string_description_field")
+ return values, nested
+
+
+def _field_records(facts, owner, values, syntax):
+ """Keep every accepted tooling field independently, avoiding list sanitation."""
+ for name, value in values.items():
+ entries = list(value.items()) if isinstance(value, dict) else list(enumerate(value)) if isinstance(value, list) else [(0, value)]
+ if len(entries) > 10_000:
+ raise MetadataError("QML_PROJECT_LIMIT", "type_field_entry_limit")
+ for ordinal, (entry_key, entry) in enumerate(entries):
+ if isinstance(entry, (list, dict)):
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "nested_type_field_literal")
+ facts.add("qmltypes_field", (owner["id"], name, ordinal), name,
+ syntax.start_byte, syntax.end_byte, owner=owner,
+ field_name=name, value=entry, position=ordinal,
+ enum_key=entry_key if isinstance(value, dict) else "", generated=True)
+
+
+def _component(syntax, facts):
+ values, nested = _bindings(syntax, facts, _COMPONENT_FIELDS)
+ name = values.get("name")
+ if not isinstance(name, str) or not name:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_component_name")
+ component = facts.add("qmltypes_component", name, name, syntax.start_byte, syntax.end_byte,
+ raw_name=name, prototype=values.get("prototype", ""),
+ singleton=values.get("isSingleton", False),
+ creatable=values.get("isCreatable", True), generated=True,
+ evidence_role="tooling_description", declared_file=values.get("file", ""))
+ _field_records(facts, component, values, syntax)
+ exports = values.get("exports", [])
+ if not isinstance(exports, list) or len(exports) > 10_000:
+ raise MetadataError("QML_PROJECT_LIMIT", "type_export_limit_or_shape")
+ for exported in exports:
+ match = re.fullmatch(r"([^/\s]+)/([^\s]+)\s+(\d{1,6}\.\d{1,6})", str(exported))
+ if not match or not uri(match[1]) or not match[2].isidentifier():
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "invalid_type_export")
+ ver = version(match[3])
+ assert ver is not None
+ facts.add("qmltypes_export", (name, exported), match[2], syntax.start_byte, syntax.end_byte,
+ owner=component, cpp_name=name, uri=match[1], raw_name=match[2],
+ major=ver[0], minor=ver[1], generated=True, evidence_role="tooling_description")
+ for ordinal, child in enumerate(nested):
+ kind = field(child, "type_name", facts.raw)
+ if kind not in {"Property", "Method", "Signal", "Enum"}:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "unknown_component_description_object")
+ fields, parameters = _bindings(child, facts, _MEMBER_FIELDS)
+ member_name = fields.get("name")
+ if not isinstance(member_name, str) or not member_name:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_member_name")
+ member = facts.add("qmltypes_member", (name, kind, member_name, ordinal), member_name,
+ child.start_byte, child.end_byte, owner=component, raw_name=member_name,
+ member_kind=kind.lower(), raw_type=fields.get("type", ""), cpp_name=name,
+ readonly=fields.get("isReadonly", False), is_list=fields.get("isList", False),
+ revision=fields.get("revision"), notify=fields.get("notify", ""),
+ generated=True, evidence_role="tooling_description")
+ _field_records(facts, member, fields, child)
+ for position, parameter in enumerate(parameters):
+ if field(parameter, "type_name", facts.raw) != "Parameter" or kind not in {"Method", "Signal"}:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "unsupported_member_child")
+ param, grandchildren = _bindings(parameter, facts, _MEMBER_FIELDS)
+ if grandchildren:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "nested_parameter_object")
+ parameter_node = facts.add("qmltypes_parameter", (name, kind, member_name, ordinal, position),
+ str(param.get("name", position)), parameter.start_byte, parameter.end_byte,
+ owner=member, raw_name=param.get("name", ""), raw_type=param.get("type", ""),
+ position=position, generated=True)
+ _field_records(facts, parameter_node, param, parameter)
+
+
+def parse_qmltypes(source: bytes | str, *, relative_file="plugins.qmltypes") -> dict:
+ try:
+ facts = ProjectFacts(source, relative_file, "qmltypes")
+ program = load_parser().parse(facts.raw).root_node
+ if program.has_error:
+ raise MetadataError("QML_TYPES_SYNTAX", "invalid_type_description_syntax")
+ pending = [(program, 0)]
+ count = 0
+ while pending:
+ node, depth = pending.pop()
+ count += 1
+ if count > 100_000 or depth > 64:
+ raise MetadataError("QML_PROJECT_LIMIT", "type_description_ast_limit")
+ pending.extend((child, depth + 1) for child in node.named_children)
+ roots = []
+ for child in program.named_children:
+ if child.type == "ui_object_definition":
+ roots.append(child)
+ elif child.type not in {"comment", "ui_import"}:
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "unsupported_top_level_description")
+ if len(roots) != 1 or field(roots[0], "type_name", facts.raw) != "Module":
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "missing_module_description")
+ module_values, components = _bindings(roots[0], facts, {"dependencies"})
+ _field_records(facts, facts.file_node, module_values, roots[0])
+ for syntax in components:
+ if field(syntax, "type_name", facts.raw) != "Component":
+ raise MetadataError("QML_TYPES_UNSUPPORTED", "unknown_module_description_object")
+ _component(syntax, facts)
+ return facts.result()
+ except (QmlInputError, UnicodeError, ValueError) as exc:
+ return failure(relative_file, "qmltypes", exc)
+
+
+def extract_qmltypes(path: Path, *, root: Path | None = None) -> dict:
+ return extract_project(path, root, "qmltypes", parse_qmltypes)
diff --git a/graphify/extractors/qt_cpp_access.py b/graphify/extractors/qt_cpp_access.py
new file mode 100644
index 0000000000..b2ed1e7610
--- /dev/null
+++ b/graphify/extractors/qt_cpp_access.py
@@ -0,0 +1,192 @@
+"""Literal C++ to QML access occurrences, retaining engine and handle provenance."""
+from __future__ import annotations
+
+from pathlib import Path
+import re
+
+from graphify.extractors.qt_cpp_access_patterns import assigned_handle, handle_argument, last_assignment, literal_map
+from graphify.extractors.qt_cpp_calls import calls, conditional_at, literal_string
+from graphify.extractors.qt_cpp_events import CPP_SUFFIXES, _failure
+from graphify.extractors.qt_cpp_facts import QtFacts, owner_scope
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_exposure import is_qt_cpp_source
+from graphify.extractors.qt_cpp_mapping import map_cpp
+from graphify.extractors.qt_cpp_identity import CppDeclarationIdentity
+from graphify.extractors.qt_cpp_loaders import (collect_loader_constructors, declared_sdk_type, literal_creation_arguments,
+ literal_loader_arguments, literal_url_expression, loader_operation_owner)
+from graphify.extractors.qt_cpp_type_scope import NativeTypeScope
+from graphify.extractors.qt_cpp_syntax import QtCppError, read_cpp, source_span
+from graphify.extractors.qt_cpp_variables import simple_reference, type_name, variables_at
+from graphify.extractors.qt_cpp_provider_expression import provider_expression
+
+_METHODS = {"load", "loadFromModule", "loadUrl", "setSource", "create", "createWithInitialProperties", "rootObjects", "rootObject",
+ "findChild", "property", "setProperty", "setParent", "invokeMethod", "read", "write", "setContextProperty", "setContextObject", "setInitialProperties"}
+
+
+def _identity_fields(identities, name, position, role):
+ """Semantic joins use exact declarations; display spellings remain provenance."""
+ value = identities.resolve(name, position)
+ fields = {role + "_declaration_id": value["declaration_id"],
+ role + "_identity_status": value["status"], role + "_identity_reason": value["reason"]}
+ if role == "provider":
+ fields["provider_scope_end"] = value["scope_span"].get("end_byte", -1)
+ return fields
+
+
+def _site(facts, unit, mapping, call, identities, types, component_engines):
+ args, method = call["args"], call["name"]
+ variables = variables_at(unit, mapping, call["start_byte"], type_scope=types)
+ receiver = call["receiver"]
+ receiver_type = variables.get(receiver, "")
+ assigned = assigned_handle(unit, call)
+ common = {"owner_class": call["owner"].get("class_name") or "", "receiver_reference": receiver,
+ "owner_scope_key": owner_scope(unit, call["owner"]),
+ "receiver_type": receiver_type, "assigned_handle": assigned_handle(unit, call),
+ "assignment_byte": last_assignment(unit, call["owner"], assigned, call["start_byte"]),
+ "receiver_assignment_byte": last_assignment(unit, call["owner"], receiver, call["start_byte"]),
+ "conditional": conditional_at(unit, call["start_byte"]), "status": "pending", "reason": "",
+ "bridge_direction": "cpp_to_qml",
+ **_identity_fields(identities, receiver, call["start_byte"], "receiver"),
+ **_identity_fields(identities, assigned, call["start_byte"], "assigned")}
+ if method in {"load", "loadFromModule", "loadUrl", "setSource", "create", "createWithInitialProperties", "rootObjects", "rootObject", "setInitialProperties"}:
+ # A reassignment loses runtime identity, but its declared SDK type still
+ # identifies the observed API occurrence. Resolution keeps that uncertainty.
+ if not receiver_type:
+ spelling, declared_at = identities.type_binding(receiver, call["start_byte"])
+ receiver_type = types.resolve(type_name(spelling), declared_at) if spelling else ""
+ # Incomplete source declarations are not external SDK authority either.
+ if (not loader_operation_owner(receiver_type, method)
+ or not declared_sdk_type(identities, types, receiver, call["start_byte"], receiver_type.removeprefix("::"))):
+ return
+ loading = method in {"load", "loadFromModule", "loadUrl", "setSource"}
+ supported = (literal_loader_arguments(receiver_type, method, args, types=types, position=call["start_byte"])
+ if loading else literal_creation_arguments(method, args))
+ values = {**common, "receiver_type": receiver_type, "operation": method,
+ "loader_supported": supported, "loader_reason": "" if supported else "loader_overload_unestablished",
+ "literal_url": literal_url_expression(args[0]["text"], types=types, identities=identities, position=call["start_byte"]) if args and method in {"load", "loadUrl", "setSource"} else None,
+ "module_uri": literal_string(args[0]["text"]) if args and method == "loadFromModule" else None,
+ "type_name": literal_string(args[1]["text"]) if len(args) >= 2 and method == "loadFromModule" else None}
+ if receiver_type == "QQmlComponent":
+ association = component_engines.get(common["receiver_declaration_id"])
+ if association is not None:
+ values.update(association)
+ if association["component_engine_supported"] is False:
+ values.update(loader_supported=False, loader_reason="component_engine_unestablished")
+ if method == "rootObjects":
+ after = unit.code[call["end_byte"]:call["end_byte"] + 40]
+ values["single_root_selection"] = bool(re.match(rb"\s*\.\s*(?:first\(\)|at\(0\))", after))
+ if method in {"createWithInitialProperties", "setInitialProperties"}:
+ values["properties"], values["literal_properties"] = literal_map(args[0]["text"] if args else "")
+ if not values["literal_properties"]:
+ values.update(status="dynamic", reason="computed_initial_properties")
+ for item in values["properties"]:
+ item["provider_type"] = variables.get(item["provider_reference"], "")
+ item.update(_identity_fields(identities, item["provider_reference"], call["start_byte"], "provider"))
+ facts.add("qml_load" if loading else "qml_root" if method in {"rootObjects", "rootObject", "create", "createWithInitialProperties"} else "initial_properties", method, call["span"], call["owner"].get("node_id"), **values)
+ elif method in {"setContextProperty", "setContextObject"}:
+ # Only direct engine.rootContext() chains or a typed QQmlContext receiver
+ # establish context provenance. Arbitrary factory/context aliases do not.
+ before = unit.code[max(0, call["start_byte"] - 120):call["start_byte"]].decode()
+ chain = re.search(r"([A-Za-z_]\w*)\s*(?:\.|->)rootContext\(\)\s*->\s*$", before)
+ engine = chain[1] if chain else ""
+ if not engine and receiver_type != "QQmlContext":
+ return
+ expression = args[1 if method == "setContextProperty" else 0]["text"] if len(args) >= (2 if method == "setContextProperty" else 1) else ""
+ declared = provider_expression(expression, identities, types, call["start_byte"])
+ provider = declared.pop("provider_reference", "")
+ facts.add("context_exposure", method, call["span"], call["owner"].get("node_id"), **common,
+ engine_reference=engine, engine_type=variables.get(engine, ""),
+ **_identity_fields(identities, engine, call["start_byte"], "engine"),
+ **_identity_fields(identities, provider, call["start_byte"], "provider"),
+ exposed_name=literal_string(args[0]["text"]) if args and method == "setContextProperty" else None,
+ provider_reference=provider, provider_type=variables.get(provider, ""), operation=method, **declared)
+ elif method == "setParent":
+ # An observed parent mutation cannot establish a static QObject tree.
+ # Keep its source occurrence for conservative child-lookup rejection.
+ facts.add("qml_parent_mutation", method, call["span"], call["owner"].get("node_id"),
+ **common, operation=method)
+ elif method in {"findChild", "property", "setProperty", "invokeMethod", "read", "write"}:
+ reflection = {}
+ qualifier = call["callee"].rsplit("::", 1)[0] if "::" in call["callee"] else ""
+ sdk = "QMetaObject" if method == "invokeMethod" else "QQmlProperty"
+ if method == "invokeMethod" or qualifier.rsplit("::", 1)[-1] == "QQmlProperty":
+ if qualifier.rsplit("::", 1)[-1] != sdk:
+ return
+ # The shared call scanner starts at an identifier. Restore an
+ # explicit global qualifier before lexical lookup and source proof.
+ if unit.code[max(0, call["start_byte"] - 2):call["start_byte"]] == b"::":
+ qualifier = "::" + qualifier
+ call = {**call, "start_byte": call["start_byte"] - 2,
+ "span": source_span(unit.source, call["start_byte"] - 2, call["end_byte"])}
+ reflection = {"reflection_type": qualifier,
+ "reflection_supported": types.sdk_type(qualifier, call["start_byte"], sdk)}
+ receiver, role = handle_argument(args[0]["text"] if args else "")
+ name = literal_string(args[1]["text"]) if len(args) > 1 else None
+ else:
+ role, name = "variable", literal_string(args[0]["text"]) if args else None
+ # Only the default and exact documented enum options prove lookup depth.
+ # Computed flags and unsupported overloads must not silently recurse.
+ option = args[1]["text"].strip() if method == "findChild" and len(args) == 2 else "" if len(args) == 1 else "unsupported"
+ depth = {"": "recursive", "Qt::FindChildrenRecursively": "recursive", "Qt::FindDirectChildrenOnly": "direct"}.get(option, "unsupported")
+ callee = unit.code[call["start_byte"]:call["end_byte"]].split(b"(", 1)[0]
+ filter_type = re.search(rb"findChild\s*<\s*((?:::)?QObject)\s*\*\s*>\s*$", callee)
+ # The bounded filter is the SDK QObject pointer, not a same-spelled
+ # lexical alias or a source-defined class with different ancestry.
+ child_type_supported = bool(filter_type and
+ types.resolve(filter_type[1].decode(), call["start_byte"]) == "QObject"
+ and not types.classes.get("QObject"))
+ facts.add("qml_access", method, call["span"], call["owner"].get("node_id"), **{**common, "receiver_reference": receiver,
+ **_identity_fields(identities, receiver, call["start_byte"], "receiver"),
+ "receiver_assignment_byte": last_assignment(unit, call["owner"], receiver, call["start_byte"])},
+ operation=method, lookup_name=name, handle_role=role, **reflection,
+ **({"find_child_options": depth, "find_child_type_supported": child_type_supported} if method == "findChild" else {}))
+
+
+def _component_constructors(unit, mapping, facts, identities, types):
+ # Property wrappers remain separate from component load construction.
+ for match in re.finditer(rb"(? 1 else None,
+ conditional=conditional_at(unit, match.start()), status="pending", reason="", bridge_direction="cpp_to_qml")
+
+
+def collect_qt_cpp_access(paths, per_file, *, root: Path, accepted_nodes=None, accepted_edges=None):
+ nodes = accepted_nodes if accepted_nodes is not None else [node for result in per_file if result for node in result.get("nodes", [])]
+ edges = accepted_edges if accepted_edges is not None else [edge for result in per_file if result for edge in result.get("edges", [])]
+ for path, result in zip(paths, per_file):
+ if not result or Path(path).suffix.lower() not in CPP_SUFFIXES or result.get("error"):
+ continue
+ try:
+ if not is_qt_cpp_source(path):
+ continue
+ unit = read_cpp(Path(path), Path(root))
+ mapping, facts = map_cpp(unit, nodes, edges, root=Path(root)), QtFacts(unit)
+ mapping.bind_classes([{"node_id": md.get("class_id", ""), "qualified_name": md.get("class_name", "")}
+ for node in nodes if (md := qt_metadata(node)).get("kind") == "class"])
+ identities = CppDeclarationIdentity(unit, mapping)
+ types = NativeTypeScope(unit, mapping)
+ component_engines = collect_loader_constructors(unit, mapping, facts, identities, types)
+ _component_constructors(unit, mapping, facts, identities, types)
+ for call in calls(unit, mapping, _METHODS):
+ _site(facts, unit, mapping, call, identities, types, component_engines)
+ result.setdefault("nodes", []).extend(facts.nodes)
+ result.setdefault("edges", []).extend(facts.edges)
+ except (QtCppError, ValueError) as error:
+ _failure(result, Path(path), Path(root), error)
diff --git a/graphify/extractors/qt_cpp_access_patterns.py b/graphify/extractors/qt_cpp_access_patterns.py
new file mode 100644
index 0000000000..019eebe0ee
--- /dev/null
+++ b/graphify/extractors/qt_cpp_access_patterns.py
@@ -0,0 +1,53 @@
+"""Literal handle assignment and initial-property maps; unsupported dataflow is opaque."""
+from __future__ import annotations
+
+import re
+
+from graphify.extractors.qt_cpp_calls import literal_string
+from graphify.extractors.qt_cpp_syntax import lexical_code, split_arguments
+
+
+def assigned_handle(unit, call):
+ before = unit.code[max(call["owner"]["body"].start_byte, unit.code.rfind(b";", 0, call["start_byte"]) + 1):call["start_byte"]].decode()
+ match = re.search(r"\b([A-Za-z_]\w*)\s*=\s*$", before)
+ return match[1] if match else ""
+
+
+def handle_argument(expression):
+ value = expression.strip()
+ root = re.fullmatch(r"([A-Za-z_]\w*)\s*(?:\.|->)\s*(?:rootObjects\(\)\.(?:first|at)\(0?\)|rootObject\(\))", value)
+ return (root[1], "root") if root else (value, "variable") if re.fullmatch(r"[A-Za-z_]\w*", value) else ("", "dynamic")
+
+
+def literal_map(expression):
+ """Recognize direct QVariantMap initializer pairs only; no computed merging."""
+ value = re.sub(r"^\s*(?:QVariantMap|QMap\s*<[^>]+>)\s*", "", expression.strip())
+ if not value.startswith("{") or not value.endswith("}"):
+ return [], False
+ pairs = []
+ for entry in split_arguments(value[1:-1].encode()):
+ if not entry.startswith("{") or not entry.endswith("}"):
+ return [], False
+ args = split_arguments(entry[1:-1].encode())
+ key = literal_string(args[0]) if len(args) == 2 else None
+ if key is None:
+ return [], False
+ reference = args[1].strip().lstrip("&")
+ pairs.append({"ordinal": len(pairs), "name": key, "provider_reference": reference if re.fullmatch(r"[A-Za-z_]\w*", reference) else "",
+ "literal_value": literal_string(args[1]), "dynamic_value": not bool(re.fullmatch(r"[A-Za-z_]\w*", reference)) and literal_string(args[1]) is None})
+ return pairs, bool(pairs)
+
+
+def handle_reassigned(unit, owner, name, start, end):
+ if not name or end <= start:
+ return False
+ code = lexical_code(unit.source[start:end]).decode()
+ return bool(re.search(r"\b" + re.escape(name) + r"\s*=(?!=)", code))
+
+
+def last_assignment(unit, owner, name, position):
+ if not name or owner.get("body") is None:
+ return -1
+ start = owner["body"].start_byte
+ matches = list(re.finditer(rb"\b" + re.escape(name.encode()) + rb"\s*=(?!=)", unit.code[start:position]))
+ return start + matches[-1].start() if matches else -1
diff --git a/graphify/extractors/qt_cpp_api_shape.py b/graphify/extractors/qt_cpp_api_shape.py
new file mode 100644
index 0000000000..3a9cdeb129
--- /dev/null
+++ b/graphify/extractors/qt_cpp_api_shape.py
@@ -0,0 +1,62 @@
+"""Original declaration shapes for the bounded statically declared QObject API.
+
+One pointer or lvalue reference is an API hop. Root objects additionally admit
+values for dot access/address-of. This does not prove runtime conversion, alias
+identity, const-correct invocation or allocation; CV stays in lexical transport.
+"""
+from __future__ import annotations
+
+import re
+
+_NAME = r"(?:::)?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*"
+_TYPE = re.compile(r"\s*(?:(?:const|volatile|struct|class)\s+)*(?P" + _NAME
+ + r")(?:(?:\s+(?:const|volatile)\b)*)\s*(?P\*(?:\s*(?:const|volatile)\b)*(?:\s*)|&)?\s*")
+
+
+def api_shape(spelling, *, allow_value=False):
+ """Reject compound declarators before resolving a convenient named base class."""
+ if not isinstance(spelling, str) or len(spelling.encode()) > 384:
+ return None
+ match = _TYPE.fullmatch(spelling)
+ if not match:
+ return None
+ suffix = match["shape"] or ""
+ kind = "pointer" if suffix.startswith("*") else "reference" if suffix == "&" else "value"
+ return (match["name"], kind) if allow_value or kind != "value" else None
+
+
+def _type_start(declaration):
+ type_node = declaration.child_by_field_name("type")
+ if type_node is None:
+ return None
+ return min([type_node.start_byte] + [node.start_byte for node in declaration.named_children
+ if node.type == "type_qualifier" and node.start_byte < type_node.end_byte])
+
+
+def return_spelling(unit, declaration, function):
+ """The prefix includes original CV and every return pointer/reference token."""
+ start = _type_start(declaration)
+ return unit.source[start:function.start_byte].decode().strip() if start is not None else ""
+
+
+def declared_spelling(unit, declaration, declarator, identifier):
+ """Remove only the selected identifier; arrays and nested pointers stay visible."""
+ start = _type_start(declaration)
+ if start is None or identifier is None:
+ return ""
+ if declarator.type == "init_declarator":
+ declarator = declarator.child_by_field_name("declarator")
+ if declarator is None:
+ return ""
+ prefix = unit.source[start:declarator.start_byte]
+ shape = unit.source[declarator.start_byte:identifier.start_byte] + unit.source[identifier.end_byte:declarator.end_byte]
+ return (prefix + shape).decode().strip()
+
+
+def declarator_identifier(node):
+ """Find a declaration leaf without erasing its enclosing shape from transport."""
+ while node is not None and node.type not in {"identifier", "field_identifier"}:
+ node = node.child_by_field_name("declarator") or next((child for child in node.named_children
+ if child.type in {"identifier", "field_identifier", "pointer_declarator", "reference_declarator",
+ "array_declarator", "parenthesized_declarator"}), None)
+ return node
diff --git a/graphify/extractors/qt_cpp_api_types.py b/graphify/extractors/qt_cpp_api_types.py
new file mode 100644
index 0000000000..f6aa6428a8
--- /dev/null
+++ b/graphify/extractors/qt_cpp_api_types.py
@@ -0,0 +1,60 @@
+"""Declaration-position API type facts for factory results and QObject properties.
+
+These facts establish declared C++ APIs, never allocation or runtime alias identity.
+Only admitted canonical complete classes can own an API endpoint.
+"""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_mapping import _name, _roles
+from graphify.extractors.qt_cpp_api_shape import api_shape, declared_spelling, declarator_identifier, return_spelling
+from graphify.extractors.qt_cpp_registration import conditional_offset
+from graphify.extractors.qt_cpp_syntax import walk, source_span
+
+
+def api_type_fields(types, raw_type, position, *, conditional=False):
+ """Resolve original declaration spelling before any later lexical alias."""
+ shape = api_shape(raw_type)
+ ids, reason = types.resolve_class(shape[0], position) if shape else ([], "api_type_shape_unsupported")
+ resolved = len(ids) == 1 and not reason and not conditional
+ return {"api_type_target_id": ids[0] if resolved else "",
+ "api_type_status": "resolved" if resolved else "dynamic" if conditional else "unsupported" if not shape else "unavailable",
+ "api_type_reason": "conditional_api_type" if conditional else reason,
+ "api_type_spelling": raw_type, "api_type_position": position}
+
+
+def return_fields(unit, record, types):
+ """Pointer/reference return syntax belongs to ancestors of the callable AST."""
+ declaration = record["syntax"]
+ declarators = [node for node in walk(declaration) if node.type == "function_declarator"]
+ own = [node for node in declarators if _name(unit, node.child_by_field_name("declarator"))
+ .split("::")[-1] == record["name"]]
+ node = own[0].parent if len(own) == 1 else None
+ while node is not None and node != declaration:
+ if node.type not in {"pointer_declarator", "reference_declarator"}:
+ return {"api_type_status": "unsupported", "api_type_reason": "return_declarator_unsupported"}
+ node = node.parent
+ raw = return_spelling(unit, declaration, own[0]) if len(own) == 1 else ""
+ return api_type_fields(types, raw, declaration.start_byte,
+ conditional=conditional_offset(unit, declaration.start_byte))
+
+
+def add_api_fields(mapping, facts, class_record, types):
+ """Public pointer fields retain their source role independently of Qt macros."""
+ unit = mapping.unit
+ body = class_record["syntax"].child_by_field_name("body")
+ for declaration in body.named_children if body else ():
+ if declaration.type != "field_declaration" or any(
+ node.type == "function_declarator" for node in walk(declaration)):
+ continue
+ _, access = _roles(unit, declaration, class_record["syntax"])
+ for declarator in declaration.children_by_field_name("declarator"):
+ name = _name(unit, declarator)
+ if not name:
+ continue
+ raw = declared_spelling(unit, declaration, declarator, declarator_identifier(declarator))
+ fields = api_type_fields(types, raw, declaration.start_byte,
+ conditional=conditional_offset(unit, declaration.start_byte))
+ facts.add("api_field", name, source_span(unit.source, declarator.start_byte, declarator.end_byte),
+ owner=class_record["node_id"] or None, class_id=class_record["node_id"],
+ class_name=class_record["qualified_name"], access=access,
+ status=class_record["status"], raw_type=raw, **fields)
diff --git a/graphify/extractors/qt_cpp_calls.py b/graphify/extractors/qt_cpp_calls.py
new file mode 100644
index 0000000000..bfe446cf64
--- /dev/null
+++ b/graphify/extractors/qt_cpp_calls.py
@@ -0,0 +1,139 @@
+"""Bounded literal/call syntax over comment/string-masked accepted C++ bytes."""
+from __future__ import annotations
+
+import json
+import re
+
+from graphify.extractors.qt_cpp_syntax import source_span, walk
+
+_CALLEE = re.compile(rb'\b(?:[A-Za-z_]\w*|this)(?:(?:::|->|\.)[A-Za-z_]\w*)*(?:\s*<[^;{}\n]*>)?\s*\(')
+_EMIT = re.compile(rb"\b(?:emit|Q_EMIT)\b(?=\s)")
+_EMIT_OVERRIDE = re.compile(rb"(?m)^[ \t]*#[ \t]*(?:define|undef)[ \t]+(emit|Q_EMIT)\b")
+
+
+def closing(code, position, opening=b"(", close=b")"):
+ depth = 0
+ for index in range(position, min(len(code), position + 100_000)):
+ token = code[index:index + 1]
+ if token == opening:
+ depth += 1
+ elif token == close:
+ depth -= 1
+ if not depth:
+ return index
+ return None
+
+
+def argument_ranges(code, start, end):
+ """Separators in nested selectors, lambdas and initializer maps are not arguments."""
+ depths, ranges, begin = [0, 0, 0, 0], [], start
+ pairs = {40: (0, 1), 41: (0, -1), 91: (1, 1), 93: (1, -1),
+ 123: (2, 1), 125: (2, -1), 60: (3, 1), 62: (3, -1)}
+ for index in range(start, end):
+ token = code[index]
+ if token in pairs:
+ slot, change = pairs[token]
+ depths[slot] = max(0, depths[slot] + change)
+ if token == 44 and not any(depths):
+ ranges.append((begin, index))
+ begin = index + 1
+ if begin < end:
+ ranges.append((begin, end))
+ return ranges
+
+
+def _explicit_calls(unit):
+ """Prove standalone annotated calls in the accepted offset-preserving AST.
+
+ Masked source rejects strings/comments; AST ownership rejects directives,
+ unevaluated expressions and macro arguments. A local annotation override
+ blocks SDK spelling authority without executing a preprocessor.
+ """
+ positions = set()
+ overrides = {}
+ for override in _EMIT_OVERRIDE.finditer(unit.code):
+ overrides.setdefault(override[1], override.start())
+ for marker in _EMIT.finditer(unit.code):
+ if overrides.get(marker[0], len(unit.code)) < marker.start():
+ continue
+ before = marker.start() - 1
+ while before >= 0 and unit.code[before] in b" \t\r\n":
+ before -= 1
+ if unit.code[max(0, before - 1):before + 1].endswith((b".", b"->", b"::")):
+ continue
+ after = marker.end()
+ while after < len(unit.code) and unit.code[after] in b" \t\r\n":
+ after += 1
+ positions.add(after)
+ if not positions:
+ return set()
+ return {(node.start_byte, node.end_byte) for node in walk(unit.root)
+ if node.type == "call_expression" and node.start_byte in positions
+ and node.parent and node.parent.type == "expression_statement" and not node.has_error}
+
+
+def calls(unit, mapping, names, *, include_explicit=False):
+ """Only executable function-body occurrences, never declaration signatures."""
+ explicit = _explicit_calls(unit) if include_explicit else set()
+ explicit_ends = {end: start for start, end in explicit}
+ for match in _CALLEE.finditer(unit.code):
+ left = match.group().rfind(b"(") + match.start()
+ callee = unit.code[match.start():left].decode().strip()
+ plain = re.sub(r"<.*>", "", callee).strip()
+ name = re.split(r"::|->|\.", plain)[-1]
+ owner = mapping.owner_at(match.start())
+ body = owner.get("body") if owner else None
+ if body is None or not (body.start_byte <= match.start() < body.end_byte):
+ continue
+ right = closing(unit.code, left)
+ if right is None:
+ continue
+ before = unit.code[max(0, match.start() - 8):match.start()].rstrip()
+ computed_receiver = not ("->" in plain or "." in plain) and before.endswith((b"->", b"."))
+ # The full accepted outer call owns annotation and source extent. A
+ # nested factory sharing its start cannot become an unknown bare signal.
+ explicit_start = explicit_ends.get(right + 1)
+ annotated = explicit_start == match.start() or (
+ explicit_start is not None and explicit_start < match.start() and computed_receiver)
+ if name not in names and not annotated:
+ continue
+ start = explicit_start if annotated and explicit_start is not None else match.start()
+ args = [{"text": unit.source[a:b].decode().strip(), "code": unit.code[a:b].decode().strip(),
+ "start_byte": a, "end_byte": b, "span": source_span(unit.source, a, b)}
+ for a, b in argument_ranges(unit.code, left + 1, right) if unit.source[a:b].strip()]
+ receiver = re.split(r"->|\.", plain)[-2] if "->" in plain or "." in plain else ""
+ record = {"name": name, "callee": plain, "receiver": receiver, "args": args, "owner": owner,
+ "computed_receiver": computed_receiver,
+ "start_byte": start, "end_byte": right + 1,
+ "span": source_span(unit.source, start, right + 1)}
+ if include_explicit:
+ record["explicit_emit"] = annotated
+ yield record
+
+
+def literal_string(expression):
+ """Decode only a literal or supported literal wrapper; never execute C++/QML."""
+ value = expression.strip()
+ for _ in range(4):
+ wrapper = re.fullmatch(r'(?:QStringLiteral|QLatin1String|QString|QByteArray|QUrl|QUrl::fromLocalFile)\s*\((.*)\)', value, re.S)
+ if wrapper:
+ value = wrapper[1].strip()
+ else:
+ break
+ if not re.fullmatch(r'(?:u8|u|U|L)?"(?:[^"\\]|\\.)*"', value, re.S):
+ return None
+ try:
+ decoded = json.loads(value[value.index('"'):])
+ except ValueError:
+ return None
+ return decoded if isinstance(decoded, str) and len(decoded.encode()) <= 256 else None
+
+
+def conditional_at(unit, position):
+ """Source conditions are evidence, not a claim that a branch executes."""
+ from graphify.extractors.qt_cpp_syntax import walk
+ for syntax in walk(unit.root):
+ if syntax.type in {"if_statement", "switch_statement", "conditional_expression", "preproc_if", "preproc_ifdef"}:
+ if syntax.start_byte <= position < syntax.end_byte:
+ return True
+ return False
diff --git a/graphify/extractors/qt_cpp_compat.py b/graphify/extractors/qt_cpp_compat.py
new file mode 100644
index 0000000000..83cdb7c45b
--- /dev/null
+++ b/graphify/extractors/qt_cpp_compat.py
@@ -0,0 +1,142 @@
+"""Narrow parse-view recovery for empty parameter defaults and Qt Q_UNUSED."""
+from __future__ import annotations
+
+import re
+
+_UNUSED = re.compile(rb"\bQ_UNUSED\s*\(")
+_EMPTY = re.compile(rb"=\s*\{\s*\}")
+_LOCAL_UNUSED = re.compile(rb"(?m)^\s*#\s*(?:define|undef)\s+Q_UNUSED\b")
+
+
+def needs_recovery(code: bytes) -> bool:
+ """Cheap admission avoids extra parse work for unrelated C++ syntax."""
+ return bool(_UNUSED.search(code) or _EMPTY.search(code))
+
+
+def _statement_code(code):
+ # Directive continuations are not function-body statements. Keep every
+ # byte/newline while excluding their tokens from candidate discovery.
+ result, continued, offset = bytearray(code), False, 0
+ for line in code.splitlines(keepends=True):
+ directive = continued or line.lstrip().startswith(b"#")
+ if directive:
+ result[offset:offset + len(line)] = re.sub(rb"[^\r\n]", b" ", line)
+ continued = directive and line.rstrip(b"\r\n").endswith(b"\\")
+ offset += len(line)
+ return bytes(result)
+
+
+def _declares_unused(tree, source, walk):
+ for node in walk(tree):
+ if node.type != "function_declarator":
+ continue
+ name = node.child_by_field_name("declarator")
+ if name and source[name.start_byte:name.end_byte] == b"Q_UNUSED":
+ return True
+ return False
+
+
+def _arguments_end(code, opening):
+ """Require balanced single-argument syntax; the AST validates expression content."""
+ depth = 1
+ for index in range(opening + 1, len(code)):
+ token = code[index]
+ if token == 40:
+ depth += 1
+ elif token == 41:
+ depth -= 1
+ if not depth:
+ return index
+ elif token == 44 and depth == 1:
+ return None
+ return None
+
+
+def _in_callable(node):
+ # The parser walk bounds depth before this ancestry check. Lambdas own
+ # executable bodies too; global initializers and signatures do not.
+ current = node.parent
+ while current:
+ if current.type in {"function_definition", "lambda_expression"}:
+ return True
+ current = current.parent
+ return False
+
+
+def _unused_statements(source, code, tree, *, parse, walk):
+ if _LOCAL_UNUSED.search(code) or _declares_unused(tree, source, walk):
+ return source
+ statement_code = _statement_code(code)
+ parsed, candidates = bytearray(source), {}
+ for match in _UNUSED.finditer(statement_code):
+ start, opening = match.start(), match.end() - 1
+ before = start - 1
+ while before >= 0 and statement_code[before] in b" \t\r\n":
+ before -= 1
+ if statement_code[max(0, before - 1):before + 1].endswith((b".", b"->", b"::")):
+ continue
+ closing = _arguments_end(statement_code, opening)
+ if closing is None:
+ continue
+ # Qt 6 defines Q_UNUSED(x) as (void)x;. Reuse the macro-name space and
+ # its closing parenthesis for the cast/semicolon. Argument bytes remain
+ # untouched, preserving evaluated nested calls and their original spans.
+ parsed[start:start + 8] = b"(void) "
+ parsed[opening], parsed[closing] = 32, 59
+ candidates[start] = closing + 1
+ if not candidates:
+ return source
+ accepted = set()
+ for node in walk(parse(bytes(parsed))):
+ statement = node.parent
+ if (node.type == "cast_expression" and not node.has_error and statement
+ and statement.type == "expression_statement" and not statement.has_error
+ and candidates.get(node.start_byte) == statement.end_byte
+ and statement.start_byte == node.start_byte and _in_callable(statement)):
+ accepted.add(node.start_byte)
+ # A proposed view is authoritative only for standalone executable macro
+ # statements. Restore declarations, return/value contexts and damaged uses.
+ for start, end in candidates.items():
+ if start not in accepted:
+ parsed[start:end] = source[start:end]
+ return bytes(parsed)
+
+
+def _empty_parameter_defaults(source, code, tree, walk):
+ parsed = bytearray(source)
+ for node in walk(tree):
+ if node.type != "optional_parameter_declaration":
+ continue
+ default = node.child_by_field_name("default_value")
+ if default is None or default.type != "compound_literal_expression":
+ continue
+ missing = default.child_by_field_name("type")
+ value = default.child_by_field_name("value")
+ equal = next((child for child in node.children if child.type == "="), None)
+ if (missing is None or not missing.is_missing or missing.type != "type_identifier"
+ or value is None or value.type != "initializer_list" or equal is None
+ or any(child.type != "comment" for child in value.named_children)
+ or not re.fullmatch(rb"\{\s*\}", code[value.start_byte:value.end_byte])):
+ continue
+ # The bundled grammar invents a missing compound-literal type for a
+ # valid empty default. Omitting only that default in the parse view
+ # preserves its declaration/signature and creates no expression fact.
+ parsed[equal.start_byte:default.end_byte] = re.sub(
+ rb"[^\r\n]", b" ", source[equal.start_byte:default.end_byte]
+ )
+ return bytes(parsed)
+
+
+def recover_cpp_syntax(source: bytes, code: bytes, *, parse, walk) -> bytes:
+ """Return an equal-length view using the caller's bounded parser/traversal.
+
+ Source is already annotation-normalized; code retains the original lexical
+ ownership view. No files are read, discovered or executed by this helper.
+ """
+ if not needs_recovery(code):
+ return source
+ tree = parse(source)
+ recovered = _unused_statements(source, code, tree, parse=parse, walk=walk)
+ if recovered != source:
+ tree = parse(recovered)
+ return _empty_parameter_defaults(recovered, code, tree, walk)
diff --git a/graphify/extractors/qt_cpp_event_patterns.py b/graphify/extractors/qt_cpp_event_patterns.py
new file mode 100644
index 0000000000..1ca184e54e
--- /dev/null
+++ b/graphify/extractors/qt_cpp_event_patterns.py
@@ -0,0 +1,57 @@
+"""Literal Qt connection endpoint syntax, never compiler/runtime emulation."""
+from __future__ import annotations
+
+import re
+
+from graphify.extractors.qt_cpp_mapping import normalize_type
+from graphify.extractors.qt_cpp_syntax import split_arguments
+
+
+def endpoint(expression):
+ value = expression.strip()
+ legacy = re.fullmatch(r"(SIGNAL|SLOT)\s*\(\s*([A-Za-z_]\w*)\s*\((.*)\)\s*\)", value, re.S)
+ if legacy:
+ return {"form": "legacy", "class_name": "", "member_name": legacy[2],
+ "parameter_types": [normalize_type(v) for v in split_arguments(legacy[3].encode())],
+ "role": "signal" if legacy[1] == "SIGNAL" else "slot", "selector": True}
+ pointer = re.search(r"&\s*((?:[A-Za-z_]\w*::)+)([A-Za-z_]\w*)", value)
+ if not pointer:
+ return {"form": "dynamic", "class_name": "", "member_name": "", "parameter_types": [], "selector": False}
+ classes, name = pointer[1].rstrip(":"), pointer[2]
+ direct = re.fullmatch(r"\s*&\s*(?:[A-Za-z_]\w*::)+[A-Za-z_]\w*\s*", value)
+ selected = re.fullmatch(r"(?:qOverload|qConstOverload|qNonConstOverload)\s*<(.*?)>\s*\(\s*&[^()]+\s*\)", value, re.S)
+ alternate = re.fullmatch(r"(?:QOverload|QConstOverload|QNonConstOverload)\s*<(.*?)>::of\s*\(\s*&[^()]+\s*\)", value, re.S)
+ cast = re.fullmatch(r"static_cast\s*<\s*[^()]+\(\s*[^()]+::\s*\*\s*\)\s*\((.*?)\)\s*(?:const)?\s*>\s*\(\s*&[^()]+\s*\)", value, re.S)
+ selector = selected or alternate or cast
+ if not direct and selector is None:
+ return {"form": "dynamic", "class_name": classes, "member_name": name, "parameter_types": [], "selector": False}
+ return {"form": "member_pointer", "class_name": classes, "member_name": name,
+ "parameter_types": [normalize_type(v) for v in split_arguments(selector[1].encode())] if selector else [],
+ "selector": selector is not None, "role": ""}
+
+
+def connection_flags(value):
+ if not value:
+ return {"declared_type": "AutoConnection", "flags": [], "explicit_type": False, "dynamic_flags": False}
+ tokens = [token.strip() for token in value.split("|")]
+ supported = {"AutoConnection", "DirectConnection", "QueuedConnection", "BlockingQueuedConnection",
+ "UniqueConnection", "SingleShotConnection"}
+ if not all(re.fullmatch(r"Qt::\w+", token) and token[4:] in supported for token in tokens):
+ return {"declared_type": "", "flags": [], "explicit_type": True, "dynamic_flags": True}
+ kinds = [token[4:] for token in tokens if token[4:] not in {"UniqueConnection", "SingleShotConnection"}]
+ return {"declared_type": kinds[0] if len(kinds) == 1 else "AutoConnection" if not kinds else "",
+ "flags": [token[4:] for token in tokens if token[4:] in {"UniqueConnection", "SingleShotConnection"}],
+ "explicit_type": bool(kinds), "dynamic_flags": len(kinds) > 1}
+
+
+def lambda_parameters(expression):
+ match = re.match(r"\s*\[[^]]*\]\s*(?:\(([^)]*)\))?", expression, re.S)
+ if not match:
+ return None
+ result = []
+ for parameter in split_arguments((match[1] or "").encode()):
+ if not re.fullmatch(r"[\w:<>*&\s]+", parameter):
+ return None
+ value = re.sub(r"\s+[A-Za-z_]\w*\s*$", "", parameter)
+ result.append(normalize_type(value))
+ return result
diff --git a/graphify/extractors/qt_cpp_events.py b/graphify/extractors/qt_cpp_events.py
new file mode 100644
index 0000000000..2162053975
--- /dev/null
+++ b/graphify/extractors/qt_cpp_events.py
@@ -0,0 +1,166 @@
+"""Qt C++ event occurrences; event delivery is never an ordinary calls edge."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from graphify.extractors.qt_cpp_calls import calls, conditional_at
+from graphify.extractors.qt_cpp_access_patterns import assigned_handle, last_assignment
+from graphify.extractors.qt_cpp_event_patterns import connection_flags, endpoint, lambda_parameters
+from graphify.extractors.qt_cpp_exposure import is_qt_cpp_source
+from graphify.extractors.qt_cpp_facts import QtFacts, owner_scope, qt_metadata, update_qt
+from graphify.extractors.qt_cpp_mapping import map_cpp
+from graphify.extractors.qt_cpp_identity import CppDeclarationIdentity
+from graphify.extractors.qt_cpp_syntax import QtCppError, read_cpp
+from graphify.extractors.qt_cpp_variables import simple_reference, variables_at
+from graphify.extractors.qt_cpp_type_scope import NativeTypeScope
+
+
+CPP_SUFFIXES = {".cpp", ".cc", ".cxx", ".c++", ".hpp", ".hh", ".hxx", ".h"}
+
+
+def _failure(result, path, root, error):
+ try:
+ name = path.resolve().relative_to(root.resolve()).as_posix()
+ except ValueError:
+ name = path.name
+ code = error.code if isinstance(error, QtCppError) else "QT_CPP_LIMIT"
+ result.setdefault("qt_failures", []).append({"code": code, "source_file": name})
+ result.setdefault("diagnostics", []).append({"code": code, "source_file": name, "severity": "error",
+ "owner": "qt_cpp", "message": "Qt source analysis could not complete", "recovery": "Correct source and retry; prior graph is preserved."})
+
+
+def _qt_owner(mapping, owner):
+ classes = [record for record in mapping.classes if record.get("node_id") == owner.get("class_id")]
+ return any(any(macro["name"] == "Q_OBJECT" for macro in record.get("macros", [])) for record in classes)
+
+
+def _typed_endpoint(expression, types, call):
+ """Member-pointer qualification obeys the same lexical type authority as locals."""
+ value = endpoint(expression)
+ if value["form"] == "member_pointer":
+ raw_name = value["class_name"]
+ resolved = types.lookup(raw_name, call["start_byte"], owner=call["owner"])
+ value.update(raw_class_name=raw_name, class_name=resolved.name)
+ if not resolved.name:
+ value.update(form="dynamic", type_reason=resolved.reason)
+ return value
+
+
+def _connection(facts, unit, mapping, call, types, identity):
+ owner, args = call["owner"], call["args"]
+ if call.get("computed_receiver") or ("::" in call["callee"] and call["callee"] != "QObject::" + call["name"]):
+ return
+ if call["callee"] != "QObject::" + call["name"] and not _qt_owner(mapping, owner):
+ return
+ if call["callee"] == call["name"] and any(function["name"] == call["name"] and function.get("class_id") == owner.get("class_id") for function in mapping.functions):
+ return
+ variables = variables_at(unit, mapping, call["start_byte"], type_scope=types)
+ signal = _typed_endpoint(args[1]["text"], types, call) if len(args) >= 2 else endpoint("")
+ receiver, context, callable_arg, flag_arg = "", "", None, ""
+ if len(args) in {4, 5}:
+ receiver = simple_reference(args[2]["text"])
+ context = receiver
+ callable_arg = args[3]
+ flag_arg = args[4]["text"] if len(args) == 5 else ""
+ elif len(args) == 3:
+ callable_arg = args[2]
+ callable_text = callable_arg["text"] if callable_arg else ""
+ parameters = lambda_parameters(callable_text)
+ receiver_endpoint = _typed_endpoint(callable_text, types, call)
+ form = "lambda" if parameters is not None else receiver_endpoint["form"]
+ callable_reference = simple_reference(callable_text)
+ if form == "dynamic" and callable_reference:
+ form = "functor" if variables.get(callable_reference) else "function"
+ sender = simple_reference(args[0]["text"]) if args else ""
+ sender_identity = identity.resolve(sender, call["start_byte"])
+ receiver_identity = identity.resolve(receiver, call["start_byte"])
+ site = facts.add(call["name"], call["name"], call["span"], owner.get("node_id"),
+ owner_class=owner.get("class_name") or "", owner_status=owner.get("status") or "unavailable",
+ owner_scope_key=owner_scope(unit, owner),
+ sender_reference=sender, sender_type=variables.get(sender, ""), signal=signal,
+ sender_declaration_id=sender_identity["declaration_id"],
+ receiver_reference=receiver, receiver_type=variables.get(receiver, ""), receiver=receiver_endpoint,
+ receiver_declaration_id=receiver_identity["declaration_id"],
+ context_reference=context, context_type=variables.get(context, ""), callable_form=form,
+ callable_reference=callable_reference, callable_type=variables.get(callable_reference, ""),
+ assigned_handle=assigned_handle(unit, call),
+ assignment_byte=last_assignment(unit, owner, assigned_handle(unit, call), call["start_byte"]),
+ handle_reference=simple_reference(args[0]["text"]) if call["name"] == "disconnect" and len(args) == 1 else "",
+ handle_assignment_byte=last_assignment(unit, owner, sender, call["start_byte"]),
+ sender_assignment_byte=last_assignment(unit, owner, sender, call["start_byte"]),
+ receiver_assignment_byte=last_assignment(unit, owner, receiver, call["start_byte"]),
+ signal_wildcard=len(args) > 1 and args[1]["text"] in {"nullptr", "0"},
+ receiver_wildcard=len(args) > 2 and args[2]["text"] in {"nullptr", "0"},
+ callable_wildcard=len(args) > 3 and args[3]["text"] in {"nullptr", "0"},
+ conditional=conditional_at(unit, call["start_byte"]),
+ status="pending", reason="", runtime_delivery="unverified", **connection_flags(flag_arg))
+ if parameters is not None and callable_arg:
+ target = facts.add("lambda", "connected lambda", callable_arg["span"], site,
+ parameter_types=parameters, callable_form="lambda")
+ update_qt(site, lambda_id=target["id"])
+
+
+def _emission(facts, unit, mapping, call, types, identity):
+ owner = call["owner"]
+ variables = variables_at(unit, mapping, call["start_byte"], type_scope=types)
+ receiver = call["receiver"] or ("" if call.get("computed_receiver") else "this")
+ # The scanner owns annotation authority; a rejected local macro override
+ # cannot regain that role through an unqualified source-prefix fallback.
+ explicit = call.get("explicit_emit") is True
+ if not explicit and not _qt_owner(mapping, owner) and not variables.get(receiver):
+ return
+ # Preserve independent typed receivers; an unbound constructor cannot infer
+ # a native `this` type solely from its syntactic class qualification.
+ receiver_type = variables.get(receiver, "")
+ if receiver == "this":
+ receiver_type = "" if owner.get("native_owner_unavailable") else receiver_type or owner.get("class_name", "")
+ facts.add("emission", call["name"], call["span"], owner.get("node_id"),
+ owner_class=owner.get("class_name") or "", receiver_reference=receiver,
+ owner_scope_key=owner_scope(unit, owner),
+ receiver_declaration_id=identity.resolve(receiver, call["start_byte"])["declaration_id"],
+ receiver_type=receiver_type,
+ member_name=call["name"], argument_count=len(call["args"]), explicit_emit=explicit,
+ conditional=conditional_at(unit, call["start_byte"]), status="pending", reason="")
+
+
+def collect_qt_cpp_events(paths, per_file, *, root: Path, accepted_nodes=None, accepted_edges=None):
+ """Enrich admitted results after canonicalization; never regenerate C++ definitions."""
+ nodes = accepted_nodes if accepted_nodes is not None else [n for r in per_file if r for n in r.get("nodes", [])]
+ edges = accepted_edges if accepted_edges is not None else [e for r in per_file if r for e in r.get("edges", [])]
+ signals = {qt_metadata(node).get("raw_name") for node in nodes if "signal" in qt_metadata(node).get("roles", [])}
+ for path, result in zip(paths, per_file):
+ if not result or Path(path).suffix.lower() not in CPP_SUFFIXES or result.get("error"):
+ continue
+ try:
+ if not is_qt_cpp_source(path):
+ continue
+ unit = read_cpp(Path(path), Path(root))
+ mapping, facts = map_cpp(unit, nodes, edges, root=Path(root)), QtFacts(unit)
+ mapping.bind_classes([{"node_id": md.get("class_id", ""), "qualified_name": md.get("class_name", "")}
+ for node in nodes if (md := qt_metadata(node)).get("kind") == "class"])
+ types = NativeTypeScope(unit, mapping)
+ identity = CppDeclarationIdentity(unit, mapping)
+ local_signals = {f["name"] for f in mapping.functions if "signal" in f.get("roles", [])}
+ for function in mapping.functions:
+ if not function.get("class_name") and function.get("node_id"):
+ facts.add("callable", function["qualified_name"], function["span"], function["node_id"],
+ generic_target_id=function["node_id"], parameter_types=function["parameter_types"],
+ callable_name=function["qualified_name"], status="resolved")
+ # Explicit accepted emission syntax survives missing declarations.
+ # The scanner's opt-in admits only those occurrences, preserving
+ # ordinary bare-call gating and every other collector's inventory.
+ for call in calls(unit, mapping, {"connect", "disconnect"} | signals | local_signals, include_explicit=True):
+ # Explicit signal syntax owns its mechanism even when a signal
+ # shares the spelling of a Qt connection API.
+ if call.get("explicit_emit") is True:
+ _emission(facts, unit, mapping, call, types, identity)
+ elif call["name"] in {"connect", "disconnect"}:
+ _connection(facts, unit, mapping, call, types, identity)
+ else:
+ _emission(facts, unit, mapping, call, types, identity)
+ known = {node["id"] for node in result.get("nodes", [])}
+ result.setdefault("nodes", []).extend(node for node in facts.nodes if node["id"] not in known)
+ result.setdefault("edges", []).extend(facts.edges)
+ except (QtCppError, ValueError) as error:
+ _failure(result, Path(path), Path(root), error)
diff --git a/graphify/extractors/qt_cpp_exposure.py b/graphify/extractors/qt_cpp_exposure.py
new file mode 100644
index 0000000000..9993200a66
--- /dev/null
+++ b/graphify/extractors/qt_cpp_exposure.py
@@ -0,0 +1,188 @@
+"""Append bounded Qt overlays to accepted C++; never replace generic declarations."""
+from __future__ import annotations
+
+from pathlib import Path
+import re
+
+from graphify.extractors.qt_cpp_facts import QtFacts, qt_metadata
+from graphify.extractors.qt_cpp_mapping import map_cpp
+from graphify.extractors.qt_cpp_properties import add_properties
+from graphify.extractors.qt_cpp_registration import add_literal_registrations, add_macro_registration, conditional_offset
+from graphify.extractors.qt_cpp_syntax import MAX_CPP_BYTES, QtCppError, lexical_code, read_cpp
+from graphify.extractors.qt_cpp_type_aliases import add_type_dependency_facts, type_dependency_source
+from graphify.extractors.qt_cpp_type_scope import NativeTypeScope
+from graphify.extractors.qt_cpp_api_types import add_api_fields, return_fields
+
+_CPP = {".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h"}
+_QT = re.compile(rb"\b(?:QML_[A-Z_]+|Q_[A-Z_]+|QObject|QQml\w*|QQuickView|qmlRegister\w*|signals|slots)\b")
+
+
+def is_qt_cpp_source(path, *, class_names=()):
+ if Path(path).suffix.lower() not in _CPP:
+ return False
+ try:
+ with Path(path).open("rb") as stream:
+ source = stream.read(MAX_CPP_BYTES + 1)
+ except OSError as exc:
+ raise QtCppError("QT_CPP_READ", "Cannot read admitted C++ source") from exc
+ code = lexical_code(source)
+ if _QT.search(code) or re.search(rb"\bemit\s+[A-Za-z_]", code):
+ return True
+ # Admission hints never bind an endpoint: parsed qualification and accepted
+ # ownership subsequently prove the actual class/method relationship.
+ return any(re.search(rb"\b" + re.escape(name.rsplit("::", 1)[-1].encode()) + rb"\s*::", code)
+ for name in class_names)
+
+
+def _failure(result, path, root, error):
+ try:
+ source = Path(path).resolve().relative_to(Path(root).resolve()).as_posix()
+ except ValueError:
+ source = ""
+ result.setdefault("qml_failures", []).append({"code": error.code, "source_file": source})
+ result.setdefault("diagnostics", []).append({"code": error.code, "severity": "error", "owner": "qt",
+ "source_file": source, "message": str(error)})
+
+
+def _class_facts(mapping, facts, *, classes=(), alias_nodes=None):
+ # Literal source occurrences can belong to a proven callable even when no
+ # Qt class definition is admitted. This is source containment only: it must
+ # not supply missing class authority, endpoint roles or QML visibility.
+ callables = {node["id"] for node in mapping.nodes if node.get("_callable") is True
+ and not node.get("_callable_class")}
+ types = NativeTypeScope(mapping.unit, mapping, classes=classes, alias_nodes=alias_nodes)
+
+ def source_owner(record):
+ return record.get("node_id", "") if (record and record.get("status") == "resolved"
+ and record.get("node_id") in callables) else ""
+
+ for record in mapping.classes:
+ owned = [macro for macro in record["macros"] if mapping.class_at(macro["start_byte"]) is record]
+ macros = {macro["name"] for macro in owned}
+ base = next((child for child in record["syntax"].named_children if child.type == "base_class_clause"), None)
+ bases = [mapping.unit.text(child) for child in base.named_children
+ if child.type not in {"access_specifier", "comment"}] if base else []
+ # Inheritance visibility belongs to each source base, independently of
+ # member visibility. Each comma restores class/struct default access;
+ # virtual and comments cannot provide or replace an access specifier.
+ default_access = "public" if record["syntax"].type == "struct_specifier" else "private"
+ access, base_access = default_access, []
+ for child in base.children if base else ():
+ if child.type == ",":
+ access = default_access
+ elif child.type == "access_specifier":
+ access = mapping.unit.text(child)
+ elif child.is_named and child.type != "comment":
+ base_access.append(access)
+ # Construction authority needs lexical base identities, not a Q_OBJECT
+ # marker or a guessed basename. Unknown header aliases remain opaque.
+ canonical_bases = [types.resolve(name, record["span"]["start_byte"]) for name in bases]
+ enclosing = mapping.owner_at(record["span"]["start_byte"])
+ facts.add("class", record["name"], record["span"],
+ owner=record["node_id"] or source_owner(enclosing) or None,
+ generic_target_id=record["node_id"], class_id=record["node_id"],
+ class_name=record["qualified_name"], status=record["status"],
+ is_definition=record["is_definition"],
+ api_declaration_conditional=conditional_offset(mapping.unit, record["span"]["start_byte"]),
+ is_qobject="Q_OBJECT" in macros or "QObject" in bases,
+ source_q_object="Q_OBJECT" in macros, is_gadget="Q_GADGET" in macros, bases=bases,
+ canonical_base_names=canonical_bases,
+ canonical_base_status="resolved" if all(canonical_bases) else "unavailable",
+ canonical_base_access=base_access,
+ canonical_base_access_status="resolved" if len(base_access) == len(bases)
+ and all(value in {"public", "protected", "private"} for value in base_access) else "unavailable")
+ add_macro_registration(mapping, facts, record)
+ add_properties(mapping.unit, mapping, facts, record, types=types)
+ add_api_fields(mapping, facts, record, types)
+ for record in mapping.functions:
+ if not record["class_name"]:
+ continue
+ if len(record["parameters"]) > 50:
+ raise QtCppError("QT_CPP_LIMIT", "Qt member exceeds 50 parameters")
+ revisions = [macro["args"] for macro in mapping.unit.macros if macro["name"] == "Q_REVISION"
+ and macro["end_byte"] <= record["span"]["start_byte"]
+ and not mapping.unit.parsed_source[macro["end_byte"]:record["span"]["start_byte"]].strip()]
+ facts.add("member", record["name"], record["span"],
+ owner=record["class_id"] or source_owner(record) or None,
+ generic_target_id=record["node_id"], class_id=record["class_id"], class_name=record["class_name"],
+ signature=record["signature"], parameter_types=record["parameter_types"],
+ parameter_names=[item["name"] for item in record["parameters"]],
+ revisions=revisions,
+ return_type=record["return_type"], roles=record["roles"], access=record["access"], status=record["status"],
+ **return_fields(mapping.unit, record, types))
+
+
+def enrich_qt_cpp(paths, per_file, *, root, accepted_nodes=None, accepted_edges=None):
+ """Use final canonical aggregate IDs; mutate fresh result slices only.
+
+ Failure facts share the QML safety channel so update can reject incomplete Qt
+ overlays. Borrowed aggregate/context dictionaries are never annotated.
+ """
+ paths, per_file = list(paths), list(per_file)
+ nodes = list(accepted_nodes) if accepted_nodes is not None else [node for result in per_file for node in result.get("nodes", [])]
+ edges = list(accepted_edges) if accepted_edges is not None else [edge for result in per_file for edge in result.get("edges", [])]
+ units, pending = [], []
+ for path, result in zip(paths, per_file):
+ try:
+ if Path(path).suffix.lower() not in _CPP:
+ continue
+ try:
+ Path(path).resolve().relative_to(Path(root).resolve())
+ except ValueError as exc:
+ raise QtCppError("QT_CPP_ROOT", "Qt C++ source is outside the scan root") from exc
+ if not is_qt_cpp_source(path):
+ pending.append((path, result))
+ continue
+ unit = read_cpp(Path(path), Path(root))
+ units.append((map_cpp(unit, nodes, edges, root=Path(root)), result))
+ except QtCppError as error:
+ _failure(result, path, root, error)
+ classes = [record for mapping, _ in units for record in mapping.classes]
+ # Borrowed complete bodies retain their source/span identity so binding can
+ # deduplicate the same accepted body reconstructed by the canonical mapper.
+ classes.extend({"node_id": md["class_id"], "qualified_name": md["class_name"],
+ "source_file": node.get("source_file"), "span": md.get("span", {}),
+ "is_definition": md.get("is_definition")}
+ for node in nodes if (md := qt_metadata(node)).get("kind") == "class"
+ and md.get("class_id") and md.get("class_name"))
+ for path, result in pending:
+ try:
+ if (is_qt_cpp_source(path, class_names=[item["qualified_name"] for item in classes])
+ or ((units or classes) and type_dependency_source(path))):
+ unit = read_cpp(Path(path), Path(root))
+ units.append((map_cpp(unit, nodes, edges, root=Path(root)), result))
+ except QtCppError as error:
+ _failure(result, path, root, error)
+ # All accepted header shadows must exist before any source registration is
+ # resolved. This run-owned snapshot adds no include discovery or execution.
+ alias_nodes = list(nodes)
+ for mapping, result in units:
+ dependencies = QtFacts(mapping.unit)
+ try:
+ add_type_dependency_facts(mapping, dependencies)
+ except (QtCppError, ValueError):
+ _failure(result, mapping.unit.path, root, QtCppError("QT_CPP_LIMIT", "Type dependency facts are unavailable"))
+ continue
+ result.setdefault("nodes", []).extend(dependencies.nodes)
+ alias_nodes.extend(dependencies.nodes)
+ for mapping, result in units:
+ mapping.bind_classes(classes)
+ facts = QtFacts(mapping.unit)
+ try:
+ _class_facts(mapping, facts, classes=classes, alias_nodes=alias_nodes)
+ # Forward declarations remain source facts, but only a parsed body
+ # establishes a registration provider; old metadata proves neither.
+ add_literal_registrations(mapping, facts, [item for item in classes if item.get("is_definition") is True],
+ alias_nodes=alias_nodes)
+ except QtCppError as error:
+ _failure(result, mapping.unit.path, root, error)
+ continue
+ except ValueError:
+ _failure(result, mapping.unit.path, root, QtCppError("QT_CPP_LIMIT", "Qt metadata exceeds transport bounds"))
+ continue
+ result.setdefault("nodes", []).extend(facts.nodes)
+ result.setdefault("edges", []).extend(facts.edges)
+ return {"nodes": [node for _, result in units for node in result.get("nodes", [])
+ if "qt" in node.get("metadata", {})],
+ "failures": [failure for result in per_file for failure in result.get("qml_failures", [])
+ if failure.get("code", "").startswith("QT_CPP_")]}
diff --git a/graphify/extractors/qt_cpp_facts.py b/graphify/extractors/qt_cpp_facts.py
new file mode 100644
index 0000000000..2bbf0219f1
--- /dev/null
+++ b/graphify/extractors/qt_cpp_facts.py
@@ -0,0 +1,124 @@
+"""Portable source-owned Qt facts with literal, bounded metadata transport."""
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+
+from graphify.ids import make_id
+
+
+def encode_qt(values: dict) -> dict:
+ """Display sanitation and semantic identity have separate representations."""
+ literals = {}
+ def visit(path, value):
+ if isinstance(value, str):
+ encoded = base64.b64encode(value.encode()).decode("ascii")
+ if len(encoded) > 512:
+ raise ValueError("QT_LIMIT: semantic field exceeds transport bound")
+ literals["/".join(path)] = encoded
+ elif isinstance(value, dict):
+ for key, child in value.items():
+ visit([*path, str(key)], child)
+ elif isinstance(value, list):
+ if len(value) > 50:
+ raise ValueError("QT_LIMIT: semantic list exceeds transport bound")
+ for index, child in enumerate(value):
+ visit([*path, str(index)], child)
+ for key, value in values.items():
+ visit([key], value)
+ if len(literals) > 100:
+ raise ValueError("QT_LIMIT: semantic fields exceed transport bound")
+ return {"contract_version": 1, **values, "raw_values": literals}
+
+
+def qt_metadata(node: dict) -> dict:
+ """Reject corrupted transport instead of interpreting escaped display strings."""
+ value = node.get("metadata", {}).get("qt", {})
+ if not isinstance(value, dict) or value.get("contract_version") != 1:
+ return {}
+ result = json.loads(json.dumps(value))
+ literals = result.get("raw_values", {})
+ if not isinstance(literals, dict) or len(literals) > 100:
+ raise ValueError("QT_METADATA: malformed literal transport")
+ for key, encoded in literals.items():
+ if not isinstance(key, str) or not isinstance(encoded, str) or len(encoded) > 512:
+ raise ValueError("QT_METADATA: malformed literal field")
+ try:
+ decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
+ parts, current = key.split("/"), result
+ for part in parts[:-1]:
+ current = current[int(part)] if isinstance(current, list) else current[part]
+ if isinstance(current, list):
+ current[int(parts[-1])] = decoded
+ else:
+ current[parts[-1]] = decoded
+ except (ValueError, UnicodeError, KeyError, IndexError, TypeError) as exc:
+ raise ValueError("QT_METADATA: malformed literal field") from exc
+ return result
+
+
+def source_span(syntax) -> dict:
+ if isinstance(syntax, dict):
+ return dict(syntax)
+ return {"start_byte": syntax.start_byte, "end_byte": syntax.end_byte,
+ "start_row": syntax.start_point.row, "end_row": syntax.end_point.row,
+ "start_column": syntax.start_point.column, "end_column": syntax.end_point.column}
+
+
+def qt_id(file: str, kind: str, semantic_key) -> str:
+ exact = json.dumps([file, kind, semantic_key], ensure_ascii=False, separators=(",", ":"))
+ return make_id("qt", file, kind, hashlib.sha256(exact.encode()).hexdigest())
+
+
+def owner_scope(unit, owner):
+ return qt_id(unit.relative_file, "owner_scope", [owner.get("qualified_name"), owner.get("signature"), owner["span"]["start_byte"], owner["span"]["end_byte"]])
+
+
+def scope_owner(metadata):
+ return metadata.get("owner_scope_key") or metadata.get("owner_id")
+
+
+class QtFacts:
+ """One accepted source owns facts; canonical C++ nodes are borrowed unchanged."""
+
+ def __init__(self, unit):
+ self.unit, self.file = unit, unit.relative_file
+ self.nodes, self.edges = [], []
+
+ def add(self, kind, name, syntax, owner=None, **fields):
+ span = source_span(syntax)
+ owner_id = owner.get("id") if isinstance(owner, dict) else owner or ""
+ semantic = fields.pop("semantic_key", [owner_id, span["start_byte"], span["end_byte"], name])
+ node = {"id": qt_id(self.file, kind, semantic), "label": f"Qt {kind}: {name}",
+ "type": "concept", "file_type": "code", "source_file": self.file,
+ "source_location": f'L{span["start_row"] + 1}-L{span["end_row"] + 1}', "_origin": "ast",
+ "metadata": {"qt": encode_qt({"kind": kind, "raw_name": name, "span": span,
+ "owner_id": owner_id, **fields})}}
+ self.nodes.append(node)
+ if owner_id:
+ self.edge(owner_id, node["id"], "contains", span, context="qt_source_site")
+ return node
+
+ def edge(self, source, target, relation, syntax, *, context, confidence="EXTRACTED", **fields):
+ span = source_span(syntax)
+ edge = {"source": source, "target": target, "_src": source, "_tgt": target,
+ "relation": relation, "confidence": confidence, "context": context,
+ "source_file": self.file, "source_location": f'L{span["start_row"] + 1}-L{span["end_row"] + 1}',
+ "metadata": {"qt": encode_qt({"span": span, "target_id": target, **fields})}}
+ self.edges.append(edge)
+ return edge
+
+
+def update_qt(node, **fields) -> None:
+ values = {k: v for k, v in qt_metadata(node).items() if k != "raw_values"}
+ node["metadata"]["qt"] = encode_qt({**values, **fields})
+
+
+def qt_edge(site, target, relation, context, **fields):
+ """Source direction survives Graphify's undirected graph and raw JSON reload."""
+ span = qt_metadata(site).get("span", {})
+ return {"source": site["id"], "target": target, "_src": site["id"], "_tgt": target,
+ "relation": relation, "confidence": "INFERRED", "context": context,
+ "source_file": site["source_file"], "source_location": site["source_location"],
+ "metadata": {"qt": encode_qt({"span": span, "target_id": target, **fields})}}
diff --git a/graphify/extractors/qt_cpp_identity.py b/graphify/extractors/qt_cpp_identity.py
new file mode 100644
index 0000000000..a2250608a6
--- /dev/null
+++ b/graphify/extractors/qt_cpp_identity.py
@@ -0,0 +1,182 @@
+"""Source-local declaration identities for Qt loaders, providers and handles.
+
+One index belongs to one accepted C++ syntax/mapping pair. Identities prove a
+lexical declaration, not runtime object equality, aliases or pointer dataflow.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+
+from graphify.extractors.qt_cpp_facts import scope_owner
+from graphify.extractors.qt_cpp_syntax import source_span, walk
+
+_SCOPES = {"compound_statement", "for_statement", "for_range_loop", "if_statement",
+ "switch_statement", "while_statement", "catch_clause", "lambda_expression"}
+_CONDITIONS = {"if_statement", "switch_statement", "conditional_expression", "for_statement",
+ "for_range_loop", "while_statement", "do_statement", "catch_clause",
+ "lambda_expression", "preproc_if", "preproc_ifdef", "preproc_else"}
+_DECLARATIONS = {"declaration", "parameter_declaration", "optional_parameter_declaration",
+ "variadic_parameter_declaration", "for_range_loop"}
+
+
+def _identifier(node):
+ """Unwrap declarators only; fields, factories and structured bindings stay opaque."""
+ while node is not None and node.type in {"pointer_declarator", "reference_declarator",
+ "parenthesized_declarator", "init_declarator"}:
+ node = node.child_by_field_name("declarator") or next(iter(node.named_children), None)
+ return node if node is not None and node.type == "identifier" else None
+
+
+def _ancestors(node):
+ while node is not None:
+ yield node
+ node = node.parent
+
+
+def _inside(node, position):
+ return node.start_byte <= position < node.end_byte
+
+
+def _answer(status, reason="", identity="", declaration_span=None, scope_span=None):
+ return {"declaration_id": identity, "status": status, "reason": reason,
+ "declaration_span": declaration_span or {}, "scope_span": scope_span or {}}
+
+
+class CppDeclarationIdentity:
+ """Bounded AST index reused by every reference in a single extraction pass."""
+
+ def __init__(self, unit, mapping):
+ self.unit, self.mapping = unit, mapping
+ self.declarations, self.writes, self.conditions = {}, [], []
+ for syntax in walk(unit.tree):
+ if syntax.type in _CONDITIONS:
+ self.conditions.append(syntax)
+ if syntax.type in {"assignment_expression", "update_expression"}:
+ left = syntax.child_by_field_name("left") or syntax.child_by_field_name("argument")
+ if left is not None and left.type == "identifier":
+ self.writes.append((unit.text(left), syntax))
+ if syntax.type not in _DECLARATIONS:
+ continue
+ for index, child in enumerate(syntax.named_children):
+ if syntax.field_name_for_named_child(index) != "declarator":
+ continue
+ name = _identifier(child)
+ if name is None:
+ continue
+ owner = mapping.owner_at(name.start_byte)
+ if not owner or owner.get("body") is None:
+ continue
+ scope = next((parent for parent in _ancestors(syntax.parent)
+ if parent.type in _SCOPES), None)
+ if syntax.type == "for_range_loop":
+ scope = syntax
+ # Function parameters and top-level locals share the function
+ # body's scope; illegal same-scope duplicates remain ambiguous.
+ scope = scope or owner["body"]
+ if not (owner["span"]["start_byte"] <= scope.start_byte
+ and scope.end_byte <= owner["span"]["end_byte"]):
+ scope = owner["body"]
+ record = {"name": unit.text(name), "name_end": name.end_byte, "syntax": child,
+ "scope": scope, "owner": owner,
+ "conditional": any(parent.type in _CONDITIONS for parent in _ancestors(syntax)),
+ "depth": sum(1 for _ in _ancestors(scope))}
+ self.declarations.setdefault(record["name"], []).append(record)
+
+ def _select(self, name, position, owner):
+ candidates = [record for record in self.declarations.get(name, [])
+ if record["owner"] is owner and record["name_end"] <= position
+ and _inside(record["scope"], position)]
+ if not candidates:
+ return None, "declaration_unavailable"
+ depth = max(record["depth"] for record in candidates)
+ nearest = [record for record in candidates if record["depth"] == depth]
+ return (nearest[0], "") if len(nearest) == 1 else (None, "declaration_ambiguous")
+
+ def _identity(self, name, owner, declaration, scope):
+ key = [self.unit.relative_file, owner.get("qualified_name"), owner.get("signature"),
+ owner["span"]["start_byte"], owner["span"]["end_byte"], name,
+ declaration.start_byte, declaration.end_byte, scope.start_byte, scope.end_byte]
+ return hashlib.sha256(json.dumps(key, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()
+
+ def type_binding(self, name, position):
+ """Original declaration spelling owns type authority, before use-site aliases.
+
+ A normalized variable type loses an explicit global qualifier. Resolve
+ its source binding instead of treating that display spelling as a new
+ type expression at the later call site.
+ """
+ # Runtime lifetime/assignment uncertainty is reported separately by
+ # resolve(); it must not erase the observed SDK operation itself.
+ if name == "this":
+ return "", position
+ owner = self.mapping.owner_at(position)
+ record, _ = self._select(name, position, owner)
+ if record is None:
+ return "", position
+ declaration = record["syntax"].parent
+ if declaration is None or declaration.type not in _DECLARATIONS:
+ return "", position
+ return self.unit.field(declaration, "type"), declaration.start_byte
+
+ def declared_type_binding(self, name, position):
+ """API roots retain original declarator shape without changing SDK lookup."""
+ from graphify.extractors.qt_cpp_api_shape import declared_spelling
+ owner = self.mapping.owner_at(position)
+ record, _ = self._select(name, position, owner)
+ if record is None:
+ return "", position
+ declarator = record["syntax"]
+ declaration = declarator.parent
+ if declaration is None or declaration.type not in _DECLARATIONS:
+ return "", position
+ return declared_spelling(self.unit, declaration, declarator, _identifier(declarator)), declaration.start_byte
+
+ def resolve(self, name, position):
+ """Return a portable 64-hex ID or explicit uncertainty, never a name fallback."""
+ if not isinstance(name, str) or not re.fullmatch(r"this|[A-Za-z_]\w*", name):
+ return _answer("unsupported", "reference_expression_unsupported")
+ owner = self.mapping.owner_at(position)
+ if not owner or owner.get("body") is None or not _inside(owner["body"], position):
+ return _answer("unavailable", "source_function_unavailable")
+ if any(_inside(condition, position) for condition in self.conditions):
+ return _answer("dynamic", "conditional_declaration_use")
+ if name == "this":
+ if not owner.get("class_id") or owner.get("native_owner_unavailable"):
+ return _answer("unavailable", "this_class_unavailable")
+ declaration, scope = owner["syntax"], owner["body"]
+ return _answer("resolved", identity=self._identity(name, owner, declaration, scope),
+ declaration_span=owner["span"],
+ scope_span=source_span(self.unit.source, scope.start_byte, scope.end_byte))
+ record, reason = self._select(name, position, owner)
+ if record is None:
+ return _answer("ambiguous" if reason == "declaration_ambiguous" else "unavailable", reason)
+ if record["conditional"]:
+ return _answer("dynamic", "conditional_declaration")
+ # A write belongs to the declaration visible at that write, not every
+ # variable with the same spelling elsewhere in this function.
+ for written, syntax in self.writes:
+ if written == name and record["name_end"] <= syntax.start_byte < position:
+ selected, _ = self._select(name, syntax.start_byte, owner)
+ if selected is record:
+ return _answer("dynamic", "reassigned_declaration")
+ declaration, scope = record["syntax"], record["scope"]
+ return _answer("resolved", identity=self._identity(name, owner, declaration, scope),
+ declaration_span=source_span(self.unit.source, declaration.start_byte, declaration.end_byte),
+ scope_span=source_span(self.unit.source, scope.start_byte, scope.end_byte))
+
+
+def reference_identity(unit, mapping, name, position):
+ """Convenience seam; collectors should reuse CppDeclarationIdentity per file."""
+ return CppDeclarationIdentity(unit, mapping).resolve(name, position)
+
+
+def source_reference_key(metadata, role="receiver"):
+ """Accepted fact transport must carry an identity; legacy names cannot join."""
+ if role not in {"receiver", "assigned", "engine", "provider", "sender"}:
+ return None
+ identity, owner = metadata.get(role + "_declaration_id"), scope_owner(metadata)
+ if not isinstance(identity, str) or not re.fullmatch(r"[0-9a-f]{64}", identity):
+ return None
+ return (owner, identity) if isinstance(owner, str) and owner else None
diff --git a/graphify/extractors/qt_cpp_loaders.py b/graphify/extractors/qt_cpp_loaders.py
new file mode 100644
index 0000000000..5c7c0c57b6
--- /dev/null
+++ b/graphify/extractors/qt_cpp_loaders.py
@@ -0,0 +1,163 @@
+"""Bounded literal URL constructors; loading never executes an analyzed engine."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from graphify.extractors.qt_cpp_calls import argument_ranges, closing, conditional_at, literal_string
+from graphify.extractors.qt_cpp_facts import owner_scope
+from graphify.extractors.qt_cpp_syntax import source_span
+from graphify.extractors.qt_cpp_variables import simple_reference, type_name, variables_at
+
+_MODES = {"QQmlComponent::PreferSynchronous", "QQmlComponent::Asynchronous"}
+
+
+def declared_sdk_type(identities, types, name, position, expected):
+ """Receiver declarations retain global/alias authority independently of display."""
+ spelling, declared_at = identities.type_binding(name, position)
+ return bool(spelling and types.sdk_type(type_name(spelling), declared_at, expected))
+
+
+def loader_operation_owner(receiver_type, operation):
+ """Only exact supported SDK classes own these source load/root mechanisms."""
+ owners = {"load": "QQmlApplicationEngine", "loadFromModule": "QQmlApplicationEngine",
+ "rootObjects": "QQmlApplicationEngine", "setInitialProperties": "QQmlApplicationEngine",
+ "setSource": "QQuickView", "rootObject": "QQuickView", "loadUrl": "QQmlComponent",
+ "create": "QQmlComponent", "createWithInitialProperties": "QQmlComponent"}
+ kind = receiver_type.removeprefix("::")
+ return kind in {"QQmlApplicationEngine", "QQuickView"} if operation == "setInitialProperties" else owners.get(operation) == kind
+
+
+def literal_loader_arguments(receiver_type, operation, args, *, types=None, position=0):
+ """Distinguish URL methods and literal compilation modes from other overloads."""
+ kind = receiver_type.removeprefix("::")
+ supported = {"load": "QQmlApplicationEngine", "loadFromModule": "QQmlApplicationEngine",
+ "setSource": "QQuickView", "loadUrl": "QQmlComponent"}
+ if supported.get(operation) != kind:
+ return False
+ if operation == "loadFromModule":
+ return len(args) == 2
+ if len(args) == 1:
+ return True
+ return (operation == "loadUrl" and len(args) == 2 and args[1]["text"].strip() in _MODES
+ and (types is None or types.sdk_type("QQmlComponent", position, "QQmlComponent")))
+
+
+def literal_creation_arguments(operation, args):
+ """A supplied context can alter source base/ownership and remains unproved."""
+ if operation in {"rootObject", "rootObjects"}:
+ return not args
+ if operation == "create":
+ return not args or len(args) == 1 and args[0]["text"].strip() in {"nullptr", "0"}
+ if operation == "createWithInitialProperties":
+ return len(args) == 1 or len(args) == 2 and args[1]["text"].strip() in {"nullptr", "0"}
+ return len(args) == 1
+
+
+def literal_url_expression(expression, *, types=None, identities=None, position=0):
+ """Keep fromLocalFile semantics: a resource URI is not an absolute file path.
+
+ Relative file paths need the application's unknown runtime working directory;
+ conservative omission avoids lending the analyzer root as that directory.
+ """
+ if types is not None:
+ # Every stripped wrapper needs source authority; a custom callable may
+ # return another URL despite having a literal written argument.
+ for wrapper in re.findall(r"(? str:
+ """Whitespace equivalence only; aliases and conversions require separate evidence."""
+ return re.sub(r"\s*([*&<>,:])\s*", r"\1", re.sub(r"\s+", " ", value).strip())
+
+
+def _name(unit, syntax):
+ if syntax is None:
+ return ""
+ if syntax.type in {"identifier", "field_identifier", "type_identifier", "qualified_identifier", "destructor_name", "operator_name"}:
+ return unit.text(syntax)
+ child = syntax.child_by_field_name("declarator")
+ if child is None and syntax.type in {"reference_declarator", "pointer_declarator", "parenthesized_declarator", "array_declarator"}:
+ child = syntax.named_children[-1] if syntax.named_children else None
+ return _name(unit, child) if child else ""
+
+
+def _qualified(unit, syntax, name):
+ scopes, parent = [], syntax.parent
+ while parent:
+ if parent.type in {"namespace_definition", "class_specifier", "struct_specifier"}:
+ scope = unit.field(parent, "name")
+ if scope:
+ scopes.append(scope)
+ parent = parent.parent
+ return "::".join(list(reversed(scopes)) + [name])
+
+
+def _line(node):
+ match = re.match(r"L(\d+)", str(node.get("source_location") or ""))
+ return int(match[1]) if match else None
+
+
+def _label(node):
+ return str(node.get("label") or "").removesuffix("()").lstrip(".").split("::")[-1]
+
+
+def _parameters(unit, declarator):
+ params = declarator.child_by_field_name("parameters")
+ values = []
+ for syntax in params.named_children if params else []:
+ if syntax.type not in {"parameter_declaration", "optional_parameter_declaration", "variadic_parameter_declaration"}:
+ continue
+ declaration = syntax.child_by_field_name("declarator")
+ name = _name(unit, declaration)
+ text = unit.text(syntax).split("=", 1)[0].strip()
+ if name:
+ position = text.rfind(name)
+ text = text[:position] + text[position + len(name):]
+ values.append({"name": name, "type": normalize_type(text)})
+ return [] if values == [{"name": "", "type": "void"}] else values
+
+
+def _roles(unit, declaration, class_syntax):
+ access = "private" if class_syntax and class_syntax.type == "class_specifier" else "public"
+ roles = []
+ if class_syntax:
+ body = class_syntax.child_by_field_name("body")
+ previous = class_syntax.start_byte
+ for child in body.named_children if body else []:
+ if child.start_byte >= declaration.start_byte:
+ break
+ if child.type == "access_specifier":
+ literal = unit.code[child.start_byte:child.end_byte].decode().strip()
+ access = "private" if "private" in literal else "protected" if "protected" in literal else "public"
+ gap_end = unit.code.find(b":", child.end_byte)
+ text = unit.code[child.start_byte:gap_end + 1].decode() if gap_end >= 0 else literal
+ roles = ["signal"] if re.search(r"\b(?:signals|Q_SIGNALS)\b", text) else ["slot"] if re.search(r"\b(?:slots|Q_SLOTS)\b", text) else []
+ previous = max(previous, child.end_byte)
+ prefix = unit.code[previous:declaration.start_byte].decode()
+ if re.search(r"\b(?:Q_SIGNAL)\b", prefix):
+ roles = list(set(roles + ["signal"]))
+ if re.search(r"\b(?:Q_SLOT)\b", prefix):
+ roles = list(set(roles + ["slot"]))
+ if re.search(r"\bQ_INVOKABLE\b", prefix):
+ roles = list(set(roles + ["invokable"]))
+ return sorted(roles), access
+
+
+class CppMapping:
+ """Borrowed generic nodes remain unchanged; source records retain ambiguity."""
+
+ def __init__(self, unit, nodes, edges, *, root):
+ self.unit, self.nodes, self.edges = unit, list(nodes), list(edges)
+ self.classes, self.functions = [], []
+ self.paths = {id(node): source_path(node, root) for node in self.nodes}
+ self.definition_paths = {id(node): source_path({"source_file": node.get("definition_file")}, root)
+ for node in self.nodes}
+ self.parents = {}
+ self.class_parents = {}
+ accepted = {node["id"]: node for node in self.nodes}
+ self.accepted = accepted
+ for edge in self.edges:
+ if edge.get("relation") in {"method", "contains"}:
+ self.parents.setdefault(edge["target"], set()).add(edge["source"])
+ parent = accepted.get(edge["source"], {})
+ if (edge.get("confidence") == "EXTRACTED" and parent.get("_callable_class") is True
+ and source_path(edge, root) == source_path(parent, root)):
+ self.class_parents.setdefault(edge["target"], set()).add(edge["source"])
+ syntax_nodes = list(walk(unit.tree))
+ for syntax in syntax_nodes:
+ if syntax.type not in {"class_specifier", "struct_specifier"}:
+ continue
+ name = unit.field(syntax, "name")
+ qualified = _qualified(unit, syntax, name)
+ matches = [node["id"] for node in self.nodes if self.paths[id(node)] == unit.relative_file
+ and class_matches(node, qualified, syntax)]
+ record = self._record(syntax, name, qualified, matches)
+ if any(class_fact(node).get("ambiguous") for node in self.nodes if node["id"] in matches):
+ record.update(status="ambiguous", node_id="")
+ record["class_id"] = record["node_id"]
+ record["is_definition"] = syntax.child_by_field_name("body") is not None
+ record["macros"] = [macro for macro in unit.macros if syntax.start_byte <= macro["start_byte"] < syntax.end_byte]
+ self.classes.append(record)
+ # A generic producer collision is not evidence that two namespaces are one.
+ for record in self.classes:
+ others = [item for item in self.classes if item["node_id"] == record["node_id"] and item["qualified_name"] != record["qualified_name"]]
+ if record["node_id"] and others:
+ record.update(status="ambiguous", node_id="", class_id="")
+ for syntax in syntax_nodes:
+ if syntax.type != "function_declarator":
+ continue
+ self._function(syntax)
+
+ def _record(self, syntax, name, qualified, matches):
+ candidates = sorted(set(matches))
+ return {"syntax": syntax, "span": source_span(self.unit.source, syntax.start_byte, syntax.end_byte),
+ "name": name, "qualified_name": qualified, "source_file": self.unit.relative_file,
+ "node_id": candidates[0] if len(candidates) == 1 else "",
+ "status": "resolved" if len(candidates) == 1 else "ambiguous" if candidates else "unavailable",
+ "candidates": candidates[:50]}
+
+ def _function(self, declarator):
+ declaration = declarator.parent
+ while declaration and declaration.type not in {"function_definition", "field_declaration", "declaration"}:
+ declaration = declaration.parent
+ if declaration is None:
+ return
+ name = _name(self.unit, declarator.child_by_field_name("declarator"))
+ if not name:
+ return
+ class_record = self.class_at(declaration.start_byte)
+ short, class_id = name.split("::")[-1], class_record["node_id"] if class_record else ""
+ # A shared constructor label must not select a neighboring overload.
+ # Use generic producer authority with the actual original occurrence.
+ constructor_owner = (class_record["qualified_name"] if class_record and short == class_record["name"]
+ else _qualified(self.unit, declaration, name).rsplit("::", 1)[0]
+ if "::" in name and name.split("::")[-2] == short else "")
+ constructor = bool(constructor_owner and declaration.child_by_field_name("type") is None)
+ matches = [node["id"] for node in self.nodes if _label(node) == short and node.get("_callable")
+ and ((class_id and class_id in self.parents.get(node["id"], ())) or
+ (not class_id and self.paths[id(node)] == self.unit.relative_file and _line(node) == declarator.start_point.row + 1))
+ and (not constructor or syntax_matches(node, declaration, self.unit.source, constructor_owner))]
+ # Canonical header merges retain the implementation's exact file/line.
+ # That source identity is stronger than a name-only class lookup, and
+ # avoids losing ownership after source_file moves back to the header.
+ definition_matches = [node["id"] for node in self.nodes if _label(node) == short
+ and node.get("_callable") is True and not node.get("_callable_class")
+ and self.definition_paths[id(node)] == self.unit.relative_file
+ and _line({"source_location": node.get("definition_location")}) == declaration.start_point.row + 1
+ and (not constructor or syntax_matches(node, declaration, self.unit.source, constructor_owner, definition_site=True))]
+ if not class_id and definition_matches:
+ matches = definition_matches
+ if not class_id and "::" in name and not definition_matches:
+ owners = {owner for candidate in matches for owner in self.parents.get(candidate, ())}
+ wanted = name.split("::")[-2]
+ owners = {node["id"] for node in self.nodes if node["id"] in owners and _label(node) == wanted and node.get("_callable_class")}
+ class_id = next(iter(owners)) if len(owners) == 1 else ""
+ qualified = _qualified(self.unit, declaration, name)
+ record = self._record(declaration, short, qualified, matches)
+ parameters = _parameters(self.unit, declarator)
+ roles, access = _roles(self.unit, declaration, class_record["syntax"] if class_record else None)
+ record.update(class_id=class_id, class_name=class_record["qualified_name"] if class_record else qualified.rsplit("::", 1)[0] if "::" in qualified else "",
+ definition_owners=sorted({owner for candidate in definition_matches for owner in self.class_parents.get(candidate, ())}),
+ parameters=parameters, parameter_types=[item["type"] for item in parameters],
+ return_type=normalize_type(self.unit.field(declaration, "type")), roles=roles, access=access,
+ body=declaration.child_by_field_name("body"), signature=short + "(" + ",".join(item["type"] for item in parameters) + ")")
+ record["out_of_line_constructor"] = (
+ class_record is None and declaration.type == "function_definition"
+ and not record["return_type"] and "::" in name and name.split("::")[-2] == short)
+ record["constructor"] = constructor
+ self.functions.append(record)
+
+ def owner_at(self, byte):
+ matches = [record for record in self.functions if record["span"]["start_byte"] <= byte < record["span"]["end_byte"]]
+ return min(matches, key=lambda record: record["span"]["end_byte"] - record["span"]["start_byte"]) if matches else None
+
+ def bind_classes(self, classes):
+ """Join exact implementation evidence to a sole accepted complete class.
+
+ Forward declarations remain source facts but do not compete with bodies.
+ Legacy metadata without an explicit body flag cannot prove completeness.
+ """
+ definitions = {}
+ for item in list(classes) + [
+ {"node_id": md.get("class_id"), "qualified_name": md.get("class_name"),
+ "source_file": node.get("source_file"), "span": md.get("span", {}),
+ "is_definition": md.get("is_definition")}
+ for node in self.nodes if (md := qt_metadata(node)).get("kind") == "class"
+ ]:
+ if item.get("is_definition") is not True or not item.get("node_id"):
+ continue
+ span = item.get("span", {})
+ identity = (item["node_id"], item["qualified_name"], item.get("source_file"),
+ span.get("start_byte"), span.get("end_byte"))
+ definitions[identity] = item
+ for record in self.functions:
+ # A constructor's source callable is independent of class authority.
+ # Never replace a rejected/legacy constructor body with a same-name
+ # header prototype after generic canonicalization declined the join.
+ if record["out_of_line_constructor"] or record.get("constructor"):
+ targets = [self.accepted[candidate] for candidate in record["candidates"]
+ if candidate in self.accepted]
+ if (len(targets) != 1 or not targets[0].get("metadata", {}).get("cpp_constructor")
+ or not constructor_class_authorized(targets[0])):
+ record.update(class_id="", native_owner_unavailable=True)
+ continue
+ if record["class_id"] or not record["class_name"]:
+ continue
+ owners = record["definition_owners"]
+ found = [item for item in definitions.values()
+ if (item["node_id"] in owners if owners else item["qualified_name"] == record["class_name"])]
+ if len(found) != 1:
+ continue
+ definition = found[0]
+ # A conflicting real body invalidates an apparently unique parent;
+ # explicit foreign qualification cannot reuse another class's owner.
+ name = definition["qualified_name"]
+ if (sum(item["qualified_name"] == name for item in definitions.values()) != 1
+ or (record["class_name"] != name and ("::" in record["class_name"]
+ or record["class_name"] != name.rsplit("::", 1)[-1]))):
+ continue
+ class_id = definition["node_id"]
+ candidates = {node["id"] for node in self.nodes if node.get("_callable") and _label(node) == record["name"]
+ and class_id in self.class_parents.get(node["id"], ()) and constructor_class_authorized(node)}
+ if owners:
+ candidates.intersection_update(record["candidates"])
+ record.update(class_id=class_id, class_name=name, node_id=next(iter(candidates)) if len(candidates) == 1 else "",
+ status="resolved" if len(candidates) == 1 else "ambiguous" if candidates else "unavailable")
+
+ def class_at(self, byte):
+ matches = [record for record in self.classes if record["span"]["start_byte"] <= byte < record["span"]["end_byte"]]
+ if matches:
+ return min(matches, key=lambda record: record["span"]["end_byte"] - record["span"]["start_byte"])
+ return None
+
+
+def map_cpp(unit, accepted_nodes, accepted_edges, *, root):
+ return CppMapping(unit, accepted_nodes, accepted_edges, root=root)
diff --git a/graphify/extractors/qt_cpp_properties.py b/graphify/extractors/qt_cpp_properties.py
new file mode 100644
index 0000000000..532f41f369
--- /dev/null
+++ b/graphify/extractors/qt_cpp_properties.py
@@ -0,0 +1,71 @@
+"""Source-backed Q_PROPERTY declarations and narrowly evidenced accessor links."""
+from __future__ import annotations
+
+import re
+
+from graphify.extractors.qt_cpp_mapping import normalize_type
+from graphify.extractors.qt_cpp_registration import conditional_offset
+from graphify.extractors.qt_cpp_api_types import api_type_fields
+
+_ATTRIBUTE = re.compile(r"\b(READ|WRITE|MEMBER|RESET|NOTIFY|REVISION|DESIGNABLE|SCRIPTABLE|STORED|USER|BINDABLE|CONSTANT|FINAL|REQUIRED)\b")
+
+
+def property_fields(macro):
+ """Keep unsupported expressions literal; never expand a preprocessor wrapper."""
+ text = macro["args"][0] if len(macro["args"]) == 1 else ""
+ tokens = list(_ATTRIBUTE.finditer(text))
+ head = text[:tokens[0].start()] if tokens else text
+ match = re.fullmatch(r"\s*(.+?)(?:\s+|(?<=[*&]))([A-Za-z_]\w*)\s*", head)
+ if not match:
+ return {"status": "unsupported", "reason": "property_declaration_unsupported", "raw_name": ""}
+ fields = {"raw_name": match[2], "raw_type": normalize_type(match[1]), "status": "resolved", "reason": ""}
+ for index, token in enumerate(tokens):
+ value = text[token.end():tokens[index + 1].start() if index + 1 < len(tokens) else len(text)].strip()
+ key = token[1].lower()
+ if key in fields:
+ fields.update(status="unsupported", reason="duplicate_property_attribute")
+ fields[key] = value or True
+ if not fields.get("read") and not fields.get("member"):
+ fields.update(status="unsupported", reason="property_read_or_member_missing")
+ return fields
+
+
+def add_properties(unit, mapping, facts, class_record, *, types=None):
+ """Properties are independent Qt facts; generic accessor IDs are borrowed."""
+ for macro in class_record["macros"]:
+ if macro["name"] != "Q_PROPERTY" or mapping.class_at(macro["start_byte"]) is not class_record:
+ continue
+ fields = property_fields(macro)
+ if conditional_offset(unit, macro["start_byte"]):
+ fields.update(status="dynamic", reason="conditional_property_declaration")
+ name = fields.pop("raw_name")
+ if types is not None:
+ fields.update(api_type_fields(types, fields.get("raw_type", ""), macro["start_byte"],
+ conditional=conditional_offset(unit, macro["start_byte"])))
+ site = facts.add("property", name, macro["span"], owner=class_record["node_id"] or None,
+ class_id=class_record["node_id"], class_name=class_record["qualified_name"],
+ generic_target_id="", **fields)
+ for role in ("read", "write", "reset", "notify"):
+ accessor = fields.get(role)
+ if not isinstance(accessor, str):
+ continue
+ candidates = [item for item in mapping.functions if item["class_id"] == class_record["node_id"]
+ and item["name"] == accessor and item["node_id"]]
+ if role == "notify":
+ candidates = [item for item in candidates if "signal" in item["roles"]
+ and item["return_type"] == "void" and item["parameter_types"] in
+ ([], [fields.get("raw_type")])]
+ elif role in {"read", "reset"}:
+ candidates = [item for item in candidates if not item["parameter_types"]]
+ elif role == "write":
+ candidates = [item for item in candidates if len(item["parameter_types"]) == 1]
+ signatures = {item["signature"] for item in candidates}
+ ids = {item["node_id"] for item in candidates}
+ status = "resolved" if len(ids) == len(signatures) == 1 else "ambiguous" if candidates else "unavailable"
+ link = facts.add("property_accessor", accessor, macro["span"], owner=site["id"],
+ semantic_key=[site["id"], role], property_id=site["id"], accessor_role=role,
+ status=status, reason="" if status == "resolved" else "accessor_not_unique_or_unavailable",
+ generic_target_id=next(iter(ids)) if status == "resolved" else "")
+ if status == "resolved":
+ facts.edge(link["id"], next(iter(ids)), "references", macro["span"],
+ context="qt_property_accessor", property_id=site["id"], accessor_role=role)
diff --git a/graphify/extractors/qt_cpp_provider_expression.py b/graphify/extractors/qt_cpp_provider_expression.py
new file mode 100644
index 0000000000..8bd33204d6
--- /dev/null
+++ b/graphify/extractors/qt_cpp_provider_expression.py
@@ -0,0 +1,29 @@
+"""Literal provider expression provenance rooted in one lexical declaration."""
+from __future__ import annotations
+
+import re
+
+from graphify.extractors.qt_cpp_api_shape import api_shape
+
+
+def provider_expression(expression, identities, types, position):
+ """Bounded zero-argument public API chains never execute a getter or factory."""
+ match = re.fullmatch(r"\s*&?\s*(this|[A-Za-z_]\w*)((?:\s*(?:->|\.)\s*[A-Za-z_]\w*\s*(?:\(\s*\))?)*)\s*", expression)
+ if not match or len(expression.encode()) > 384:
+ return {}
+ reference = match[1]
+ steps = [{"operator": item[1], "name": item[2], "role": "call" if item[3] else "field"}
+ for item in re.finditer(r"(->|\.)\s*([A-Za-z_]\w*)\s*(\(\s*\))?", match[2])]
+ if len(steps) > 8:
+ return {}
+ spelling, declared_at = identities.declared_type_binding(reference, position)
+ if reference == "this":
+ owner = identities.mapping.owner_at(position)
+ spelling, declared_at = (owner.get("class_name", "") + "*", owner["span"]["start_byte"]) if owner else ("", position)
+ shape = api_shape(spelling, allow_value=True)
+ ids, reason = types.resolve_class(shape[0], declared_at) if shape else ([], "provider_type_shape_unsupported")
+ return {"provider_reference": reference, "provider_expression_steps": steps,
+ "provider_root_type_spelling": spelling, "provider_root_address_of": expression.lstrip().startswith("&"),
+ "provider_root_type_id": ids[0] if len(ids) == 1 and not reason else "",
+ "provider_type_reason": reason,
+ "provider_type_status": "resolved" if len(ids) == 1 and not reason else "unsupported" if not shape else "unavailable"}
diff --git a/graphify/extractors/qt_cpp_registration.py b/graphify/extractors/qt_cpp_registration.py
new file mode 100644
index 0000000000..904857213e
--- /dev/null
+++ b/graphify/extractors/qt_cpp_registration.py
@@ -0,0 +1,125 @@
+"""Literal Qt registration facts, retaining unsupported and conditional evidence."""
+from __future__ import annotations
+
+import json
+import re
+
+from graphify.extractors.qt_cpp_mapping import normalize_type
+from graphify.extractors.qt_cpp_syntax import split_arguments, walk
+from graphify.extractors.qt_cpp_type_scope import NativeTypeScope
+
+_SUPPORTED = {"qmlRegisterType", "qmlRegisterUncreatableType", "qmlRegisterSingletonType",
+ "qmlRegisterSingletonInstance", "qmlRegisterAnonymousType"}
+_IDENTIFIER = re.compile(r"[A-Za-z_]\w*")
+
+
+def literal_string(value):
+ if not re.fullmatch(r'"(?:[^"\\]|\\["\\nrt])*"', value):
+ return None
+ try:
+ return json.loads(value)
+ except ValueError:
+ return None
+
+
+def conditional(syntax):
+ parent = syntax.parent
+ while parent:
+ if parent.type in {"if_statement", "switch_statement", "for_statement", "while_statement",
+ "do_statement", "conditional_expression", "preproc_if", "preproc_ifdef", "preproc_else"}:
+ return True
+ parent = parent.parent
+ return False
+
+
+def conditional_offset(unit, byte):
+ return any(node.type in {"preproc_if", "preproc_ifdef", "preproc_else"}
+ and node.start_byte <= byte < node.end_byte for node in walk(unit.tree))
+
+
+def class_target(unit, syntax, raw_type, classes, *, type_scope=None):
+ """Qualified lexical candidates only, never a corpus-wide basename lookup."""
+ scope = type_scope if type_scope is not None else NativeTypeScope(unit, classes=classes)
+ return scope.resolve_class(normalize_type(raw_type), syntax.start_byte)
+
+
+def add_macro_registration(mapping, facts, record):
+ owned = [macro for macro in record["macros"] if mapping.class_at(macro["start_byte"]) is record]
+ macros = {macro["name"]: macro for macro in owned}
+ named = macros.get("QML_NAMED_ELEMENT") or macros.get("QML_ELEMENT") or macros.get("QML_ANONYMOUS")
+ if named is None:
+ return
+ name = record["name"] if named["name"] == "QML_ELEMENT" else ""
+ status, reason = record["status"], "" if record["node_id"] else "class_mapping_unavailable"
+ if named["name"] == "QML_NAMED_ELEMENT":
+ args = named["args"]
+ if len(args) == 1 and _IDENTIFIER.fullmatch(args[0]):
+ name = args[0]
+ else:
+ status, reason = "unsupported", "named_element_requires_identifier"
+ flags = {"singleton": "QML_SINGLETON" in macros, "anonymous": "QML_ANONYMOUS" in macros,
+ "creatable": "QML_UNCREATABLE" not in macros and "QML_ANONYMOUS" not in macros}
+ allowed = {"QML_ELEMENT", "QML_NAMED_ELEMENT", "QML_ANONYMOUS", "QML_SINGLETON", "QML_UNCREATABLE",
+ "QML_ADDED_IN_VERSION", "QML_REMOVED_IN_VERSION"}
+ unsupported = sorted(name for name in macros if name.startswith("QML_") and name not in allowed)
+ if unsupported:
+ status, reason = "unsupported", "foreign_extended_or_attached_mapping_unsupported"
+ if conditional(record["syntax"]) or conditional_offset(mapping.unit, named["start_byte"]):
+ status, reason = "dynamic", "conditional_exposure"
+ fields = {"class_id": record["node_id"], "generic_target_id": record["node_id"],
+ "class_name": record["qualified_name"], "mechanism": named["name"], "status": status,
+ "reason": reason, "module_source_file": mapping.unit.relative_file, "module_required": True,
+ "unsupported_macros": unsupported, **flags}
+ fields["annotations"] = [{key: macro[key] for key in ("name", "args", "span")}
+ for macro in owned if macro["name"].startswith("QML_")]
+ for macro_name, key in (("QML_ADDED_IN_VERSION", "added_version"), ("QML_REMOVED_IN_VERSION", "removed_version")):
+ macro = macros.get(macro_name)
+ if macro:
+ args = macro["args"]
+ if len(args) == 2 and all(re.fullmatch(r"\d+", arg) for arg in args):
+ fields[key] = [int(arg) for arg in args]
+ else:
+ fields.update(status="unsupported", reason="exposure_version_expression_unsupported")
+ facts.add("registration", name, named["span"], owner=record["node_id"] or None, **fields)
+
+
+def add_literal_registrations(mapping, facts, classes, *, alias_nodes=None):
+ unit = mapping.unit
+ type_scope = NativeTypeScope(unit, mapping, classes=classes, alias_nodes=alias_nodes)
+ for syntax in walk(unit.tree):
+ if syntax.type != "call_expression":
+ continue
+ function = syntax.child_by_field_name("function")
+ name = unit.field(function, "name") if function and function.type == "template_function" else unit.text(function)
+ if not re.fullmatch(r"qmlRegister[A-Za-z_]\w*", name):
+ continue
+ template = function.child_by_field_name("arguments") if function else None
+ types = split_arguments(unit.source[template.start_byte + 1:template.end_byte - 1]) if template else []
+ arguments = syntax.child_by_field_name("arguments")
+ args = split_arguments(unit.source[arguments.start_byte + 1:arguments.end_byte - 1]) if arguments else []
+ owner = mapping.owner_at(syntax.start_byte)
+ fields = {"mechanism": name, "module_required": False, "status": "unsupported",
+ "reason": "literal_registration_arguments_unsupported", "class_id": "", "generic_target_id": "",
+ "raw_type": types[0] if types else "", "class_name": "", "singleton": "Singleton" in name,
+ "anonymous": name == "qmlRegisterAnonymousType", "creatable": name == "qmlRegisterType"}
+ raw_name = ""
+ needed = 2 if fields["anonymous"] else 4 if name == "qmlRegisterType" else 5
+ if name not in _SUPPORTED:
+ fields["reason"] = "registration_mechanism_unsupported"
+ supported_template = len(types) == 1 or (len(types) == 2 and bool(re.fullmatch(r"\d+", types[1])))
+ if name in _SUPPORTED and supported_template and len(args) == needed:
+ if len(types) == 2:
+ fields["metaobject_revision"] = int(types[1])
+ uri = literal_string(args[0])
+ version_args = args[1:2] if fields["anonymous"] else args[1:3]
+ raw_name = "" if fields["anonymous"] else literal_string(args[3])
+ ids, reason = class_target(unit, syntax, types[0], classes, type_scope=type_scope)
+ if uri and raw_name is not None and all(re.fullmatch(r"\d+", arg) for arg in version_args):
+ fields.update(uri=uri, major=int(version_args[0]), minor=0 if fields["anonymous"] else int(version_args[1]),
+ class_id=ids[0] if len(ids) == 1 else "", generic_target_id=ids[0] if len(ids) == 1 else "",
+ status="resolved" if len(ids) == 1 else "ambiguous" if ids else "unavailable", reason=reason)
+ records = [record for record in classes if record["node_id"] == fields["class_id"]]
+ fields["class_name"] = records[0]["qualified_name"] if records else ""
+ if conditional(syntax):
+ fields.update(status="dynamic", reason="conditional_registration")
+ facts.add("registration", raw_name or "", syntax, owner=owner["node_id"] if owner and owner["node_id"] else None, **fields)
diff --git a/graphify/extractors/qt_cpp_syntax.py b/graphify/extractors/qt_cpp_syntax.py
new file mode 100644
index 0000000000..57239a5022
--- /dev/null
+++ b/graphify/extractors/qt_cpp_syntax.py
@@ -0,0 +1,234 @@
+"""Bounded C++ source views for Qt overlays; no preprocessing or corpus execution."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+import re
+
+from tree_sitter import Language, Node, Parser
+import tree_sitter_cpp
+
+from graphify.extractors.qt_cpp_compat import needs_recovery, recover_cpp_syntax
+
+MAX_CPP_BYTES = 5_000_000
+_MACRO = re.compile(rb"\b(?:QML_[A-Z_]+|Q_(?:OBJECT|GADGET|PROPERTY|INVOKABLE|SIGNAL|SLOT|SIGNALS|SLOTS|ENUM(?:_NS)?|FLAG(?:_NS)?|REVISION|CLASSINFO|INTERFACES|DECLARE_METATYPE|EMIT)|SIGNAL|SLOT)\b")
+_RAW_STRING = re.compile(rb'(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(')
+_NUMBER = re.compile(rb"(?:[0-9]|\.[0-9])(?:[A-Za-z0-9_.]|'[A-Za-z0-9_]|(?<=[eEpP])[+-])*")
+_MEMBER_CAST = re.compile(rb"\bstatic_cast\s*<[\w:\s*&<>]+\(\s*[\w:]+\s*::\s*\*\s*\)\s*\([\w:\s*&<>,]*\)\s*(?:const\s*)?>\s*\(\s*(&\s*[\w:]+\s*::\s*[A-Za-z_]\w*)\s*\)")
+
+
+class QtCppError(Exception):
+ """A bounded stage code is safe to publish instead of backend exception text."""
+
+ def __init__(self, code: str, message: str):
+ self.code = code
+ super().__init__(message)
+
+
+def blank(source: bytes) -> bytes:
+ return re.sub(rb"[^\r\n]", b" ", source)
+
+
+def lexical_code(source: bytes) -> bytes:
+ """Mask comments/quoted/raw literals in place, including continued comments."""
+ result, index = bytearray(source), 0
+ while index < len(source):
+ # C++ preprocessing numbers own digit separators. Skip their complete
+ # lexical token before testing apostrophes as character-literal starts;
+ # the parser still validates the unchanged numeric syntax itself.
+ number = _NUMBER.match(source, index)
+ if number and (index == 0 or source[index - 1] not in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"):
+ index = number.end()
+ continue
+ start, end = index, index
+ if source[index:index + 2] == b"//":
+ end = index + 2
+ while end < len(source):
+ if source[end:end + 1] == b"\n" and source[end - 1:end] != b"\\" and source[end - 2:end] != b"\\\r":
+ break
+ end += 1
+ elif source[index:index + 2] == b"/*":
+ close = source.find(b"*/", index + 2)
+ end = len(source) if close < 0 else close + 2
+ else:
+ raw = _RAW_STRING.match(source, index)
+ if raw:
+ close = source.find(b")" + raw[1] + b'"', raw.end())
+ end = len(source) if close < 0 else close + len(raw[1]) + 2
+ elif source[index:index + 1] in {b'"', b"'"}:
+ quote, end = source[index], index + 1
+ while end < len(source):
+ if source[end] == 92:
+ end += 2
+ elif source[end] == quote:
+ end += 1
+ break
+ else:
+ end += 1
+ if end > start:
+ end = min(end, len(source))
+ result[start:end] = blank(source[start:end])
+ index = end
+ else:
+ index += 1
+ return bytes(result)
+
+
+def split_arguments(source: bytes) -> list[str]:
+ """Split literal call arguments while preserving nested expressions as opaque text."""
+ code, depth, start, values = lexical_code(source), 0, 0, []
+ for index, char in enumerate(code):
+ if char in b"([{<":
+ depth += 1
+ elif char in b")]}>" and depth:
+ depth -= 1
+ elif char == 44 and not depth:
+ values.append(source[start:index].decode().strip())
+ start = index + 1
+ if source.strip():
+ values.append(source[start:].decode().strip())
+ return values
+
+
+def source_span(source: bytes, start: int, end: int) -> dict:
+ result = {"start_byte": start, "end_byte": end}
+ for side, offset in (("start", start), ("end", end)):
+ prefix = source[:offset]
+ result[side + "_row"] = prefix.count(b"\n")
+ result[side + "_column"] = offset - prefix.rfind(b"\n") - 1
+ return result
+
+
+def walk(node):
+ """Iterative traversal has the same explicit work bounds as parser admission."""
+ pending = [(node, 0)]
+ count = 0
+ while pending:
+ current, depth = pending.pop()
+ count += 1
+ if count > 100_000 or depth > 256:
+ raise QtCppError("QT_CPP_LIMIT", "Qt C++ AST exceeds size/depth bounds")
+ yield current
+ pending.extend((child, depth + 1) for child in reversed(current.named_children))
+
+
+@dataclass
+class CppUnit:
+ path: Path
+ relative_file: str
+ source: bytes
+ code: bytes
+ parsed_source: bytes
+ tree: Node
+ macros: list[dict]
+
+ @property
+ def root(self):
+ return self.tree
+
+ def text(self, node) -> str:
+ return self.source[node.start_byte:node.end_byte].decode() if node else ""
+
+ def field(self, node, name: str) -> str:
+ return self.text(node.child_by_field_name(name)) if node else ""
+
+
+def _normalized(source: bytes, code: bytes):
+ """Known Qt annotation spellings become whitespace, preserving every offset."""
+ parsed, macros = bytearray(source), []
+ # The bundled grammar rejects valid explicit member-pointer overload casts.
+ # Keep only the inner pointer in its original byte position for AST recovery;
+ # native selectors consume the unchanged source/code views and full signature.
+ for match in _MEMBER_CAST.finditer(code):
+ start, end = match.span()
+ parsed[start:end] = blank(source[start:end])
+ parsed[match.start(1):match.end(1)] = source[match.start(1):match.end(1)]
+ for match in _MACRO.finditer(code):
+ start, end = match.span()
+ line_start = code.rfind(b"\n", 0, start) + 1
+ if code[line_start:start].lstrip().startswith(b"#"):
+ continue
+ cursor = end
+ while cursor < len(code) and code[cursor] in b" \t\r\n":
+ cursor += 1
+ args = []
+ if cursor < len(code) and code[cursor] == 40:
+ depth, argument_start = 1, cursor + 1
+ cursor += 1
+ while cursor < len(code) and depth:
+ depth += (code[cursor] == 40) - (code[cursor] == 41)
+ cursor += 1
+ if depth:
+ raise QtCppError("QT_CPP_SYNTAX", "Qt annotation parentheses are incomplete")
+ end, args = cursor, split_arguments(source[argument_start:cursor - 1])
+ name = match[0].decode()
+ macros.append({"name": name, "start_byte": start, "end_byte": end,
+ "args": args, "span": source_span(source, start, end)})
+ parsed[start:end] = blank(source[start:end])
+ if name in {"Q_SIGNALS", "Q_SLOTS"} and code[end:].lstrip().startswith(b":"):
+ if name == "Q_SIGNALS" or not re.search(rb"\b(?:public|protected|private)\s*$", code[line_start:start]):
+ parsed[start:start + 6] = b"public"
+ elif name in {"SIGNAL", "SLOT"} and args:
+ parsed[start:start + 1] = b"0"
+ # Native section keywords and emissions are syntax annotations, not calls.
+ for match in re.finditer(rb"\b(signals|slots|emit)\b", code):
+ start, end = match.span()
+ after = code[end:].lstrip()
+ if match[0] == b"signals" and after.startswith(b":"):
+ parsed[start:end] = b"public "
+ elif match[0] == b"slots" and after.startswith(b":"):
+ parsed[start:end] = b" " * (end - start)
+ elif match[0] == b"emit" and re.match(rb"[A-Za-z_]", after):
+ parsed[start:end] = b" " * (end - start)
+ return recover_cpp_syntax(bytes(parsed), code, parse=_parse_cpp, walk=walk), macros
+
+
+def _parse_cpp(source: bytes) -> Node:
+ """Compatibility recovery and final admission share the parser diagnostic."""
+ try:
+ return Parser(Language(tree_sitter_cpp.language())).parse(source).root_node
+ except Exception as exc:
+ raise QtCppError("QT_CPP_PARSER", "Cannot parse Qt C++ source") from exc
+
+
+def normalize_qt_cpp(source: bytes) -> bytes | None:
+ """Generic C++ parsing shares offset-preserving recovery without Qt overlays.
+
+ Invalid annotation recovery remains the overlay's explicit safety failure;
+ the generic extractor retains its existing malformed-input behavior.
+ """
+ if len(source) > MAX_CPP_BYTES:
+ return None
+ code = lexical_code(source)
+ if not needs_recovery(code) and not _MACRO.search(code) and not re.search(rb"\b(?:signals|slots)\s*:|\bemit\s+[A-Za-z_]|\bQObject\s*::\s*(?:connect|disconnect)\s*\(", code):
+ return None
+ try:
+ normalized, _ = _normalized(source, code)
+ except QtCppError:
+ return None
+ return normalized if normalized != source else None
+
+
+def read_cpp(path: Path, root: Path) -> CppUnit:
+ """Read one already-admitted source; Qt overlays never discover more files."""
+ try:
+ relative = path.resolve().relative_to(root.resolve()).as_posix()
+ except ValueError as exc:
+ raise QtCppError("QT_CPP_ROOT", "Qt C++ source is outside the scan root") from exc
+ try:
+ if path.stat().st_size > MAX_CPP_BYTES:
+ raise QtCppError("QT_CPP_LIMIT", "Qt C++ source exceeds 5 MB")
+ with path.open("rb") as stream:
+ source = stream.read(MAX_CPP_BYTES + 1)
+ source.decode("utf-8")
+ except (OSError, UnicodeError) as exc:
+ raise QtCppError("QT_CPP_READ", "Cannot read UTF-8 Qt C++ source") from exc
+ if len(source) > MAX_CPP_BYTES:
+ raise QtCppError("QT_CPP_LIMIT", "Qt C++ source exceeds 5 MB")
+ code = lexical_code(source)
+ normalized, macros = _normalized(source, code)
+ tree = _parse_cpp(normalized)
+ list(walk(tree))
+ if tree.has_error:
+ raise QtCppError("QT_CPP_SYNTAX", "Qt C++ syntax is incomplete or outside the supported profile")
+ return CppUnit(path, relative, source, code, normalized, tree, macros)
diff --git a/graphify/extractors/qt_cpp_type_aliases.py b/graphify/extractors/qt_cpp_type_aliases.py
new file mode 100644
index 0000000000..f9b2156e58
--- /dev/null
+++ b/graphify/extractors/qt_cpp_type_aliases.py
@@ -0,0 +1,181 @@
+"""Accepted header alias shadows; no include execution or corpus discovery.
+
+Facts retain original declarations and literal includes. Included aliases block
+outer spelling fallback; this module never pretends to preprocess their targets.
+"""
+from __future__ import annotations
+
+from collections import defaultdict
+from pathlib import Path, PurePosixPath
+import posixpath
+import re
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.cpp_class_proof import class_fact
+from graphify.extractors.cpp_constructors import _decoded
+from graphify.extractors.qt_cpp_syntax import MAX_CPP_BYTES, QtCppError, lexical_code, walk
+from graphify.qml_resolution_types import source_path
+
+_IDENTIFIER = re.compile(r"[A-Za-z_]\w*")
+_QUALIFIED = re.compile(r"[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*")
+_DEPENDENCY = re.compile(rb"\b(?:using\s+(?:[A-Za-z_]\w*\s*=|(?:::)?[A-Za-z_]\w*::)|typedef\b|namespace\s+[A-Za-z_]\w*\s*=)")
+_HEADERS = {".h", ".hpp", ".hh", ".hxx"}
+
+
+def type_dependency_source(path):
+ """Inspect only an explicitly admitted header, bounded like the native reader."""
+ if Path(path).suffix.lower() not in _HEADERS:
+ return False
+ try:
+ with Path(path).open("rb") as stream:
+ source = stream.read(MAX_CPP_BYTES + 1)
+ except OSError as exc:
+ raise QtCppError("QT_CPP_READ", "Cannot read admitted header dependency") from exc
+ # Comments must not admit an otherwise unrelated header. Include literals
+ # are retained for the admission hint only; parsed facts provide authority.
+ code = lexical_code(source)
+ includes = re.finditer(rb"#\s*include", code)
+ return bool(_DEPENDENCY.search(code) or any(re.match(rb'\s*"', source[match.end():]) for match in includes))
+
+
+def _include(unit, syntax):
+ literal = unit.field(syntax, "path")
+ if not (literal.startswith('"') and literal.endswith('"')):
+ return ""
+ value = literal[1:-1]
+ return value if value and len(value.encode()) <= 384 and "\\" not in value and not PurePosixPath(value).is_absolute() else ""
+
+
+def add_type_dependency_facts(mapping, facts):
+ """One source owns standalone namespace/class aliases and literal includes."""
+ from graphify.extractors.qt_cpp_type_scope import NativeTypeScope
+ index = NativeTypeScope(mapping.unit, mapping)
+ for (scopes, name), records in index.bindings.items():
+ if any(scope[0] == "block" for scope in scopes):
+ continue
+ scope = scopes[-1] if scopes else ("global", "")
+ for syntax, target, kind, conditional in records:
+ if kind not in {"alias_declaration", "type_definition", "using_declaration", "namespace_alias_definition"}:
+ continue
+ facts.add("type_alias", name, syntax, scope_name=scope[1], scope_kind=scope[0],
+ binding_kind=kind, target_spelling=target, conditional=conditional,
+ status="source", reason="included_alias_target_unverified")
+ for syntax in walk(mapping.unit.tree):
+ if syntax.type == "preproc_include" and (literal := _include(mapping.unit, syntax)):
+ facts.add("type_include", literal, syntax, value=literal, status="source")
+
+
+def _accepted(node):
+ md = qt_metadata(node)
+ if md.get("kind") not in {"type_alias", "type_include"}:
+ return None
+ file, span = node.get("source_file"), md.get("span", {})
+ valid_file = (isinstance(file, str) and file and "\\" not in file and ":" not in file
+ and not PurePosixPath(file).is_absolute() and ".." not in PurePosixPath(file).parts)
+ numeric = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column")
+ valid_span = (isinstance(span, dict) and all(type(span.get(key)) is int and span[key] >= 0 for key in numeric)
+ and span["start_byte"] < span["end_byte"] and span["start_row"] <= span["end_row"]
+ and (span["start_row"] != span["end_row"] or span["start_column"] < span["end_column"]))
+ if not valid_file or not valid_span or node.get("_origin") != "ast" or node.get("file_type") != "code":
+ raise ValueError("QT_METADATA: invalid type dependency provenance")
+ if node.get("source_location") != f'L{span["start_row"] + 1}-L{span["end_row"] + 1}':
+ raise ValueError("QT_METADATA: invalid type dependency location")
+ if md["kind"] == "type_alias" and node.get("label") != f'Qt type_alias: {md.get("raw_name", "")}':
+ raise ValueError("QT_METADATA: invalid type dependency name")
+ if md["kind"] == "type_alias":
+ scope, name = md.get("scope_name"), md.get("raw_name")
+ if (not isinstance(scope, str) or (scope and not _QUALIFIED.fullmatch(scope))
+ or not isinstance(name, str) or not _IDENTIFIER.fullmatch(name)
+ or md.get("scope_kind") not in {"global", "namespace", "class"}
+ or (md["scope_kind"] == "global") != (scope == "")):
+ raise ValueError("QT_METADATA: invalid type alias scope")
+ elif md.get("value") != md.get("raw_name") or not isinstance(md.get("value"), str):
+ raise ValueError("QT_METADATA: invalid type include literal")
+ return file, md
+
+
+def _class_shadow(node, unit):
+ """Generic source declarations can block SDK fallback without defining it.
+
+ Complete-body/Qt provider admission remains separate. A forward or ambiguous
+ class spelling must still shadow an external SDK name in an included header.
+ No extra header is admitted, opened, reparsed or executed by this lookup.
+ """
+ metadata = node.get("metadata")
+ if not isinstance(metadata, dict) or "cpp_class" not in metadata:
+ return None
+ fact = class_fact(node)
+ # Direct extraction may use absolute in-root paths and has no origin stamp;
+ # the aggregate supplies portable paths and explicit AST origin. Normalize
+ # only already-accepted provenance against this unit's verified scan root.
+ root = unit.path.resolve().parents[len(PurePosixPath(unit.relative_file).parts) - 1]
+ file = source_path(node, root)
+ if (not fact or not file or node.get("_origin") not in {None, "ast"}
+ or node.get("file_type") != "code"):
+ raise ValueError("QT_METADATA: invalid included native class provenance")
+ qualified = _decoded(fact["qualified_name_b64"])
+ scope, _, name = qualified.rpartition("::")
+ return file, (scope, name)
+
+
+class IncludedAliasShadows:
+ """Immutable accepted dependency snapshot, with bounded include graph walks."""
+
+ def __init__(self, unit, nodes):
+ self.unit = unit
+ self.aliases, self.includes = defaultdict(set), defaultdict(set)
+ self.classes = defaultdict(set)
+ paths = set()
+ for node in nodes:
+ shadow = _class_shadow(node, unit)
+ if shadow is not None:
+ file, spelling = shadow
+ paths.add(file)
+ self.classes[file].add(spelling)
+ record = _accepted(node)
+ if record is None:
+ continue
+ file, md = record
+ paths.add(file)
+ if md["kind"] == "type_alias":
+ self.aliases[file].add((md["scope_name"], md["raw_name"]))
+ else:
+ self.includes[file].add(md["value"])
+ self.paths = frozenset(paths)
+ self.direct = tuple((syntax.end_byte, _include(unit, syntax)) for syntax in walk(unit.tree)
+ if syntax.type == "preproc_include" and _include(unit, syntax))
+
+ def _target(self, owner, literal):
+ # Quoted includes search the source directory before the explicit scan
+ # root. Only accepted fact paths participate; no header is opened here.
+ for base in (posixpath.dirname(owner), ""):
+ target = posixpath.normpath(posixpath.join(base, literal))
+ if target in self.paths and not target.startswith("../"):
+ return target
+ return ""
+
+ def _contains(self, inventory, scope, name, byte):
+ key = scope[-1][1] if scope and scope[-1][0] != "block" else "" if not scope else None
+ if key is None:
+ return False
+ pending = [self._target(self.unit.relative_file, literal) for end, literal in self.direct if end <= byte]
+ seen = set()
+ while pending:
+ file = pending.pop()
+ if not file or file in seen:
+ continue
+ seen.add(file)
+ if len(seen) > 128:
+ raise QtCppError("QT_CPP_LIMIT", "Type dependency walk exceeds 128 accepted headers")
+ if (key, name) in inventory[file]:
+ return True
+ pending.extend(self._target(file, literal) for literal in self.includes[file])
+ return False
+
+ def blocks(self, scope, name, byte):
+ """Imported aliases retain no compiler-expanded native target authority."""
+ return self._contains(self.aliases, scope, name, byte)
+
+ def class_declared(self, scope, name, byte):
+ """A source class is a shadow, independently of whether its body maps."""
+ return self._contains(self.classes, scope, name, byte)
diff --git a/graphify/extractors/qt_cpp_type_scope.py b/graphify/extractors/qt_cpp_type_scope.py
new file mode 100644
index 0000000000..e061ada77b
--- /dev/null
+++ b/graphify/extractors/qt_cpp_type_scope.py
@@ -0,0 +1,213 @@
+"""Source-local native type authority without preprocessing or compiler emulation.
+
+Lexical bindings own their declaration scope and source order. A shadowing alias
+that cannot be proved blocks outer lookup; it never falls back to an unrelated
+global class. Accepted class identities remain owned by the canonical producer.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+import re
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_syntax import walk
+from graphify.extractors.qt_cpp_type_aliases import IncludedAliasShadows
+
+_NAME = re.compile(r"(?:::)?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*")
+_CONDITIONAL = {"if_statement", "switch_statement", "for_statement", "while_statement",
+ "do_statement", "conditional_expression", "preproc_if", "preproc_ifdef", "preproc_else"}
+
+
+@dataclass(frozen=True)
+class TypeLookup:
+ name: str = ""
+ reason: str = ""
+ source_declared: bool = False
+
+
+class NativeTypeScope:
+ """Immutable per-unit lexical index; external spellings are not class proof."""
+
+ def __init__(self, unit, mapping=None, *, classes=(), alias_nodes=None):
+ self.unit, self.mapping = unit, mapping
+ self.syntax = tuple(walk(unit.tree))
+ self.bindings = {}
+ self.classes = {}
+ self.included = IncludedAliasShadows(unit, alias_nodes if alias_nodes is not None
+ else mapping.nodes if mapping is not None else ())
+ records = list(classes) + (list(mapping.classes) if mapping is not None else [])
+ if mapping is not None:
+ records += [{"qualified_name": md.get("class_name"), "node_id": md.get("class_id"),
+ "is_definition": md.get("is_definition")}
+ for node in mapping.nodes if (md := qt_metadata(node)).get("kind") == "class"]
+ for record in records:
+ name, target = record.get("qualified_name"), record.get("node_id")
+ if name and target and record.get("is_definition") is True:
+ self.classes.setdefault(name, set()).add(target)
+ self.namespaces = {name.rsplit("::", 1)[0] for name in self.classes if "::" in name}
+ self.namespaces |= {prefix for name in tuple(self.namespaces)
+ for prefix in self._prefixes(name)}
+ # Alias/type bindings are independent of generic graph nodes. Missing
+ # canonical identity may reject a target but cannot erase a shadow.
+ for node in self.syntax:
+ kind, name, target = node.type, "", ""
+ if kind == "alias_declaration":
+ name, target = unit.field(node, "name"), unit.field(node, "type")
+ elif kind == "type_definition":
+ # Each declarator introduces a separate shadow, including
+ # unsupported pointer/function aliases and comma-separated names.
+ owner = mapping.owner_at(node.start_byte) if mapping is not None else None
+ scopes = self._scopes(node.parent, owner)
+ for declarator in node.children_by_field_name("declarator"):
+ leaf = declarator
+ while leaf and leaf.type not in {"type_identifier", "identifier"}:
+ leaf = leaf.child_by_field_name("declarator") or next(iter(leaf.named_children), None)
+ name = unit.text(leaf) if leaf else ""
+ target = unit.field(node, "type") if leaf == declarator else ""
+ if re.fullmatch(r"[A-Za-z_]\w*", name):
+ self.bindings.setdefault((scopes, name), []).append((node, target, kind, self._conditional(node)))
+ continue
+ elif kind == "using_declaration":
+ literal = unit.text(node).removeprefix("using").strip().removesuffix(";").strip()
+ if not literal.startswith("namespace ") and _NAME.fullmatch(literal):
+ name, target = literal.rsplit("::", 1)[-1], literal
+ elif kind in {"class_specifier", "struct_specifier"}:
+ name = unit.field(node, "name")
+ target = self._qualified(self._scopes(node.parent), name)
+ elif kind == "namespace_definition":
+ name = unit.field(node, "name")
+ if name and "::" not in name:
+ target = self._qualified(self._scopes(node.parent), name)
+ self.namespaces.add(target)
+ elif kind == "namespace_alias_definition":
+ name = unit.field(node, "name")
+ target = unit.text(node).split("=", 1)[-1].strip().removesuffix(";").strip()
+ if not name or not re.fullmatch(r"[A-Za-z_]\w*", name):
+ continue
+ owner = mapping.owner_at(node.start_byte) if mapping is not None else None
+ scopes = self._scopes(node.parent, owner)
+ record = (node, target, kind, self._conditional(node))
+ self.bindings.setdefault((scopes, name), []).append(record)
+
+ @staticmethod
+ def _prefixes(name):
+ parts = name.split("::")
+ return ["::".join(parts[:end]) for end in range(1, len(parts) + 1)]
+
+ def _scopes(self, node, owner=None):
+ ancestors = []
+ while node is not None:
+ ancestors.append(node)
+ node = node.parent
+ scopes, qualified = [], ""
+ for ancestor in reversed(ancestors):
+ if ancestor.type in {"namespace_definition", "class_specifier", "struct_specifier"}:
+ name = self.unit.field(ancestor, "name")
+ if name:
+ qualified = "::".join(filter(None, (qualified, name)))
+ scopes.append(("class" if ancestor.type != "namespace_definition" else "namespace", qualified))
+ elif ancestor.type == "compound_statement":
+ # Out-of-line bodies retain their accepted class scope, while
+ # block keys isolate disjoint local aliases with the same name.
+ if owner and owner.get("class_name") and not any(key[0] == "class" for key in scopes):
+ scopes.append(("class", owner["class_name"]))
+ scopes.append(("block", ancestor.start_byte, ancestor.end_byte))
+ return tuple(scopes)
+
+ @staticmethod
+ def _qualified(scopes, name):
+ prefix = next((scope[1] for scope in reversed(scopes) if scope[0] != "block"), "")
+ return "::".join(filter(None, (prefix, name)))
+
+ @staticmethod
+ def _conditional(node):
+ node = node.parent
+ while node is not None:
+ if node.type in _CONDITIONAL:
+ return True
+ node = node.parent
+ return False
+
+ def _at(self, byte, owner):
+ enclosing = [node for node in self.syntax if node.start_byte <= byte < node.end_byte]
+ node = min(enclosing, key=lambda item: item.end_byte - item.start_byte) if enclosing else self.unit.tree
+ return self._scopes(node, owner)
+
+ def lookup(self, raw_type, byte, *, owner=None):
+ """Resolve a literal type spelling, rejecting uncertainty before fallback."""
+ if not isinstance(raw_type, str) or len(raw_type.encode()) > 384 or not _NAME.fullmatch(raw_type):
+ return TypeLookup(reason="native_type_expression_unsupported")
+ if owner is None and self.mapping is not None:
+ owner = self.mapping.owner_at(byte)
+ return self._lookup(raw_type, byte, self._at(byte, owner), frozenset())
+
+ def _lookup(self, name, byte, scopes, seen):
+ absolute, name = name.startswith("::"), name.removeprefix("::")
+ head, separator, tail = name.partition("::")
+ choices = [()] if absolute else [scopes[:end] for end in range(len(scopes), -1, -1)]
+ for scope in choices:
+ if self.included.blocks(scope, head, byte):
+ return TypeLookup(reason="native_type_included_alias_unavailable")
+ records = [record for record in self.bindings.get((scope, head), ()) if record[0].start_byte <= byte]
+ if records:
+ direct = {target for _, target, kind, _ in records
+ if kind in {"class_specifier", "struct_specifier", "namespace_definition"}}
+ if len(records) != 1 and not (len(direct) == 1 and all(
+ kind in {"class_specifier", "struct_specifier", "namespace_definition"}
+ for _, _, kind, _ in records)):
+ return TypeLookup(reason="native_type_binding_ambiguous")
+ node, target, kind, condition = records[0]
+ if condition:
+ return TypeLookup(reason="native_type_binding_conditional")
+ if kind in {"class_specifier", "struct_specifier", "namespace_definition"}:
+ result = TypeLookup(target, source_declared=True)
+ else:
+ identity = (node.start_byte, node.end_byte)
+ if identity in seen or len(seen) >= 32:
+ return TypeLookup(reason="native_type_alias_cycle_or_limit")
+ if not _NAME.fullmatch(target):
+ return TypeLookup(reason="native_type_alias_unsupported")
+ result = self._lookup(target, node.start_byte - 1, scope, seen | {identity})
+ return TypeLookup(result.name + separator + tail, result.reason, result.source_declared) if result.name else result
+ if scope and scope[-1][0] == "class" and self.bindings.get((scope, head)):
+ return TypeLookup(reason="native_type_declaration_not_visible")
+ candidate = self._qualified(scope, head)
+ # Included source declarations block SDK fallback even without a
+ # complete mapped body. Native target lookup still requires the
+ # existing accepted complete-class inventory; this adds no provider.
+ if self.included.class_declared(scope, head, byte):
+ return TypeLookup(candidate + separator + tail, source_declared=True)
+ if (not scope or scope[-1][0] != "block") and (candidate in self.classes or candidate in self.namespaces):
+ return TypeLookup(candidate + separator + tail, source_declared=True)
+ # A later local alias cannot authorize an earlier use. Retaining an
+ # unshadowed external spelling preserves SDK input for its own guards.
+ if any(self.bindings.get((scope, head)) for scope in choices):
+ return TypeLookup(reason="native_type_declaration_not_visible")
+ return TypeLookup(name)
+
+ def resolve(self, raw_type, byte, *, owner=None):
+ return self.lookup(raw_type, byte, owner=owner).name
+
+ def sdk_type(self, raw_type, byte, expected):
+ """Authorize an external SDK type, never a same-spelled source declaration.
+
+ Source-body identity is deliberately unnecessary for rejection: a forward
+ declaration or unmapped class still shadows the SDK spelling. Absolute
+ lookup bypasses local aliases but retains global source shadowing.
+ """
+ result = self.lookup(raw_type, byte)
+ return result.name == expected and not result.reason and not result.source_declared
+
+ def callable_shadow(self, name, byte, *, absolute=False):
+ """Visible source callables cannot inherit SDK type-conversion semantics."""
+ if self.mapping is None:
+ return False
+ scopes = self._at(byte, self.mapping.owner_at(byte))
+ names = {name} if absolute else {name} | {
+ scope[1] + "::" + name for scope in scopes if scope[0] != "block"}
+ return any(record.get("qualified_name") in names for record in self.mapping.functions)
+
+ def resolve_class(self, raw_type, byte, *, owner=None):
+ result = self.lookup(raw_type, byte, owner=owner)
+ ids = sorted(self.classes.get(result.name, ())) if result.name else []
+ return ids, (result.reason or ("" if len(ids) == 1 else "class_mapping_ambiguous_or_unavailable" if ids else "class_not_in_corpus"))
diff --git a/graphify/extractors/qt_cpp_variables.py b/graphify/extractors/qt_cpp_variables.py
new file mode 100644
index 0000000000..8c44363040
--- /dev/null
+++ b/graphify/extractors/qt_cpp_variables.py
@@ -0,0 +1,82 @@
+"""Bounded source-local C++ type evidence; no arbitrary pointer/value dataflow."""
+from __future__ import annotations
+
+import re
+
+from graphify.extractors.qt_cpp_syntax import walk
+from graphify.extractors.qt_cpp_type_scope import NativeTypeScope
+
+
+def simple_reference(value):
+ value = value.strip()
+ if value.startswith("&"):
+ value = value[1:].strip()
+ return value if re.fullmatch(r"this|[A-Za-z_]\w*", value) else ""
+
+
+def type_name(value):
+ return re.sub(r"\b(?:const|volatile|struct|class)\b|[&*]", "", value).strip()
+
+
+def _name(unit, syntax):
+ if syntax is None:
+ return ""
+ while syntax.type in {"pointer_declarator", "reference_declarator", "init_declarator"}:
+ syntax = syntax.child_by_field_name("declarator") or next(iter(syntax.named_children), None)
+ if syntax is None:
+ return ""
+ return unit.text(syntax) if syntax.type == "identifier" else ""
+
+
+def variables_at(unit, mapping, position, *, type_scope=None):
+ """Parameters/local explicit types are authoritative only within their owner."""
+ owner = mapping.owner_at(position)
+ if not owner:
+ return {}
+ types = type_scope if type_scope is not None else NativeTypeScope(unit, mapping)
+ # Parameter types belong to their declaration, before any body-local alias.
+ variables = {parameter["name"]: types.resolve(type_name(parameter["type"]), owner["span"]["start_byte"], owner=owner)
+ for parameter in owner.get("parameters", []) if parameter.get("name")}
+ enclosing = mapping.class_at(position)
+ # A source-proven constructor callable may still lack an authorized class
+ # join; its spelling alone cannot give `this` a definite native type.
+ variables["this"] = "" if owner.get("native_owner_unavailable") else (
+ enclosing.get("qualified_name") if enclosing else owner.get("class_name")) or ""
+ body = owner.get("body")
+ if body is None:
+ return variables
+ for syntax in walk(body):
+ if syntax.start_byte >= position:
+ continue
+ ancestor, visible = syntax.parent, True
+ while ancestor and ancestor != body:
+ if ancestor.type in {"compound_statement", "lambda_expression", "for_statement", "for_range_loop", "catch_clause"}:
+ if not ancestor.start_byte <= position < ancestor.end_byte:
+ visible = False
+ break
+ ancestor = ancestor.parent
+ if not visible:
+ continue
+ if syntax.type in {"parameter_declaration", "optional_parameter_declaration"}:
+ parameter = _name(unit, syntax.child_by_field_name("declarator"))
+ if parameter:
+ variables[parameter] = types.resolve(type_name(unit.field(syntax, "type")), syntax.start_byte, owner=owner)
+ if syntax.type == "declaration":
+ name, value = _name(unit, syntax.child_by_field_name("declarator")), unit.field(syntax, "type")
+ if not name:
+ continue
+ declared = type_name(value)
+ if declared == "auto":
+ declarator = syntax.child_by_field_name("declarator")
+ initial = unit.field(declarator, "value") if declarator else ""
+ match = re.fullmatch(r"new\s+([A-Za-z_]\w*(?:::\w+)*)\s*(?:\([^;]*\)|\{[^;]*\})?", initial)
+ declared = match[1] if match else ""
+ variables[name] = types.resolve(declared, syntax.start_byte, owner=owner) if declared else ""
+ elif syntax.type in {"assignment_expression", "update_expression"}:
+ left = syntax.child_by_field_name("left") or syntax.child_by_field_name("argument")
+ name = unit.text(left) if left else ""
+ if name in variables:
+ # Even an explicitly typed pointer may now refer to another
+ # runtime instance; retain no handle identity across reassignment.
+ variables[name] = ""
+ return variables
diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py
index a0b7eff7d6..ea5efe0ac2 100644
--- a/graphify/extractors/resolution.py
+++ b/graphify/extractors/resolution.py
@@ -2942,6 +2942,7 @@ def _source_stem(node: dict) -> str:
def _merge_decl_def_classes(
all_nodes: list[dict],
all_edges: list[dict],
+ all_raw_calls=(),
) -> None:
"""Merge a class (and its methods) declared in a header with its definition in
a sibling impl file into ONE node, for C/C++/ObjC (#1547, #1556).
@@ -2989,6 +2990,13 @@ def _merge_decl_def_classes(
member ``_make_id(class_id, "bar")``. So every decl/def member pair is itself an
id-collision across the same sibling file set and collapses by the same rule.
"""
+ # AST constructor qualification, complete bodies and matching prototypes own
+ # this join; a header collision alone cannot prove a constructor declaration.
+ from graphify.extractors.cpp_constructors import bind_cpp_constructors, constructor_merge_allowed
+ from graphify.extractors.cpp_class_proof import class_body_keeper, class_merge_allowed
+ from graphify.extractors.cpp_member_identity import canonicalize_cpp_members, member_merge_allowed
+ canonicalize_cpp_members(all_nodes, all_edges, all_raw_calls)
+ bind_cpp_constructors(all_nodes, all_edges)
# Group every code node by id, recording the distinct source files involved.
by_id: dict[str, list[dict]] = {}
for n in all_nodes:
@@ -3006,6 +3014,8 @@ def _merge_decl_def_classes(
for nid, group in by_id.items():
if len(group) < 2:
continue
+ if not constructor_merge_allowed(group) or not class_merge_allowed(group) or not member_merge_allowed(group):
+ continue
# The distinct source files of this collision must form a clean sibling
# header/impl set with exactly one header. Each file must parse as a
# header/impl file (others -> bail), share one directory + base stem.
@@ -3039,6 +3049,9 @@ def _merge_decl_def_classes(
if len(base_headers) > 1:
continue
keeper = base_headers[0] if base_headers else min(headers, key=_source_stem)
+ # Class identity stays unchanged, but an earlier forward header cannot
+ # replace the only complete body's authoritative source file and span.
+ keeper = class_body_keeper(group, keeper)
# The keeper is the DECLARATION, so without this the graph reports the
# header as the symbol's only location and the definition site — the file
# and line a reader actually wants — is discarded with the dropped node.
diff --git a/graphify/graph_direction.py b/graphify/graph_direction.py
new file mode 100644
index 0000000000..849234898c
--- /dev/null
+++ b/graphify/graph_direction.py
@@ -0,0 +1,29 @@
+"""Accepted edge-pair orientation shared by dependency and source-owner consumers.
+
+Markers may reverse undirected storage, but cannot supply foreign endpoints or
+partly replace raw orientation. Unmarked legacy edges keep their original pair.
+"""
+from __future__ import annotations
+
+
+def logical_endpoints(source, target, data, *, directed):
+ """Return the accepted logical pair, or None for inconsistent direction transport."""
+ if "_src" not in data and "_tgt" not in data:
+ return source, target
+ if "_src" not in data or "_tgt" not in data:
+ return None
+ src, tgt = data["_src"], data["_tgt"]
+ # Exact endpoint types prevent bool/integer coercion from authorizing a
+ # malformed serialized ID. Equality alone is insufficient for that case.
+ if type(src) is type(source) and type(tgt) is type(target) and src == source and tgt == target:
+ return src, tgt
+ if (not directed and type(src) is type(target) and type(tgt) is type(source)
+ and src == target and tgt == source):
+ return src, tgt
+ return None
+
+
+def valid_direction_pairs(edges, *, directed):
+ """Preflight explicit edge tuples before any comparison or publication shortcut."""
+ return all(logical_endpoints(source, target, data, directed=directed) is not None
+ for source, target, data in edges)
diff --git a/graphify/path_provenance.py b/graphify/path_provenance.py
new file mode 100644
index 0000000000..4f2b64084b
--- /dev/null
+++ b/graphify/path_provenance.py
@@ -0,0 +1,37 @@
+"""Readable, bounded source evidence shared by CLI and MCP path rendering."""
+from __future__ import annotations
+
+from graphify.security import sanitize_label
+
+
+def path_provenance(nodes, edges) -> str:
+ """Render actual declaration and relationship sources without metadata dumps.
+
+ Path traversal owns ordering and direction. Missing locations stay absent;
+ this view never substitutes a target declaration for an edge's call site.
+ """
+ lines = []
+ for data in nodes:
+ if source := _source(data):
+ label = sanitize_label(str(data.get("label") or "node"))
+ lines.append(f" Node {label}: {source}")
+ for data in edges:
+ if source := _source(data):
+ relation = sanitize_label(str(data.get("relation") or "related"))
+ confidence = sanitize_label(str(data.get("confidence") or ""))
+ context = sanitize_label(str(data.get("context") or ""))
+ suffix = f" [{confidence}]" if confidence else ""
+ if context:
+ suffix += f" ({context})"
+ lines.append(f" Edge {relation}{suffix}: {source}")
+ return "\nSource evidence:\n" + "\n".join(dict.fromkeys(lines)) if lines else ""
+
+
+def _source(data):
+ path = data.get("source_file")
+ if not isinstance(path, str) or not path:
+ return ""
+ path = sanitize_label(path[:512])
+ location = data.get("source_location")
+ location = sanitize_label(location[:128]) if isinstance(location, str) else ""
+ return f"{path}:{location}" if location else path
diff --git a/graphify/paths.py b/graphify/paths.py
index 8cfca2e65d..97fc20ba08 100644
--- a/graphify/paths.py
+++ b/graphify/paths.py
@@ -26,6 +26,20 @@
GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
+def _unlink_replace_file(path: "str | Path") -> None:
+ """Consume an owned replacement file, clearing only its Windows read-only bit."""
+ try:
+ os.unlink(path)
+ except PermissionError:
+ mode = os.stat(path, follow_symlinks=False).st_mode
+ if os.name != "nt" or not stat.S_ISREG(mode) or mode & stat.S_IWRITE:
+ raise
+ # Copies preserve the source mode. Windows cannot unlink that owned
+ # read-only copy/source; never follow a link or alter its target here.
+ os.chmod(path, mode | stat.S_IWRITE)
+ os.unlink(path)
+
+
def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None:
"""``os.replace(src, dst)``, falling back to a copy for a known set of
Windows quirks (#3508) that raise even when ``src``/``dst`` are the same
@@ -47,8 +61,9 @@ def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None:
or file) aside as a backup rather than deleting it outright, and only
then renames the temp copy into ``dst``'s place -- matching replace's
"whatever was there is gone, a plain file replaces it" semantics. If that
- final rename fails, the backup is renamed straight back so a mid-swap
- failure leaves the original in place rather than leaving ``dst`` missing.
+ final rename or cleanup fails, restore the source and prior destination.
+ A second OS failure during restoration is an explicit integrity failure;
+ the recovery backup is retained rather than silently discarded.
"""
try:
os.replace(src, dst)
@@ -56,6 +71,7 @@ def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None:
except OSError as exc:
if not isinstance(exc, PermissionError) and getattr(exc, "winerror", None) != 17:
raise
+ replace_error = exc
import shutil
dst = os.fspath(dst)
if os.path.normcase(os.path.abspath(os.fspath(src))) == os.path.normcase(os.path.abspath(dst)):
@@ -64,38 +80,62 @@ def os_replace_with_fallback(src: "str | Path", dst: "str | Path") -> None:
# the "back up dst" step and then crash unlinking a path that no
# longer exists at the end.
return
+ try:
+ destination_mode = os.stat(dst, follow_symlinks=False).st_mode
+ except FileNotFoundError:
+ destination_mode = None
+ # A fallback must not bypass a real destination rejection. On Windows a
+ # read-only file can be renamed aside but not removed: doing so previously
+ # published new bytes and then reported failure during source cleanup.
+ # lstat semantics keep direct symlink replacement distinct from its target.
+ if destination_mode is not None and (stat.S_ISDIR(destination_mode) or (
+ os.name == "nt" and stat.S_ISREG(destination_mode)
+ and not destination_mode & stat.S_IWRITE)):
+ raise replace_error
dst_dir = os.path.dirname(dst) or "."
fd, tmp_copy = tempfile.mkstemp(dir=dst_dir, prefix=".gfy-replace-", suffix=".tmp")
os.close(fd)
+ backup = None
+ backup_moved = landed = source_removed = False
try:
shutil.copy2(src, tmp_copy)
- backup = None
if os.path.lexists(dst):
bfd, backup = tempfile.mkstemp(dir=dst_dir, prefix=".gfy-replace-bak-", suffix=".tmp")
os.close(bfd)
os.unlink(backup) # reserve the name only; rename needs it free on Windows
os.rename(dst, backup) # a plain rename moves a symlink itself, never its target
- try:
- os.rename(tmp_copy, dst)
- except BaseException:
- if backup is not None:
- try:
- os.rename(backup, dst)
- except OSError:
- pass # best-effort restore; the swap failure below still propagates
- raise
+ backup_moved = True
+ os.rename(tmp_copy, dst)
+ landed = True
+ # Keep the prior destination until both move cleanup operations have
+ # completed. A cleanup exception must not mean newly published data.
+ _unlink_replace_file(src)
+ source_removed = True
if backup is not None:
- try:
- os.unlink(backup)
- except OSError:
- pass
+ _unlink_replace_file(backup)
except BaseException:
+ restore_error = None
try:
- os.unlink(tmp_copy)
- except OSError:
- pass
+ if landed:
+ if source_removed:
+ if os.path.lexists(src):
+ raise OSError("Replacement source changed during rollback")
+ shutil.copy2(dst, src)
+ os.rename(dst, tmp_copy)
+ if backup_moved and backup is not None:
+ os.rename(backup, dst)
+ except OSError as error:
+ restore_error = error
+ try:
+ if os.path.lexists(tmp_copy):
+ _unlink_replace_file(tmp_copy)
+ except OSError as error:
+ if restore_error is None:
+ raise OSError("Atomic replacement cleanup failed; destination restored") from error
+ if restore_error is not None:
+ recovery = "recovery backup retained" if backup and os.path.lexists(backup) else "manual recovery required"
+ raise OSError("Atomic replacement rollback failed; " + recovery) from restore_error
raise
- os.unlink(src)
def _atomic_replace(path: "str | Path", write_fn) -> None:
@@ -518,6 +558,24 @@ def load_node_link_graph(path_or_data):
if isinstance(data, dict) and "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
try:
- return json_graph.node_link_graph(data, edges="links")
+ graph = json_graph.node_link_graph(data, edges="links")
except TypeError: # networkx too old for the edges kwarg; default is "links"
- return json_graph.node_link_graph(data)
+ graph = json_graph.node_link_graph(data)
+ if not graph.is_directed():
+ # Export stores the producer's orientation in serialized endpoints.
+ # NetworkX's undirected iteration order cannot recover that contract.
+ for link in data.get("links", []):
+ metadata = link.get("metadata", {})
+ qml = metadata.get("qml", {}) if isinstance(metadata, dict) else {}
+ qt = metadata.get("qt", {}) if isinstance(metadata, dict) else {}
+ if not any(isinstance(value, dict) and value.get("contract_version") == 1 for value in (qml, qt)):
+ continue
+ source, target = link["source"], link["target"]
+ attributes = (graph.edges[source, target, link.get("key", 0)]
+ if graph.is_multigraph() else graph.edges[source, target])
+ # Only an absent pair may be restored from serialized endpoints.
+ # Explicit partial/foreign markers remain visible for consumer
+ # rejection; reload must not turn corrupt transport into proof.
+ if "_src" not in attributes and "_tgt" not in attributes:
+ attributes["_src"], attributes["_tgt"] = source, target
+ return graph
diff --git a/graphify/publication.py b/graphify/publication.py
new file mode 100644
index 0000000000..9a705901b0
--- /dev/null
+++ b/graphify/publication.py
@@ -0,0 +1,209 @@
+"""Caller-owned product transactions; AST caches and runtime execution are separate.
+
+Products are prepared on disk before publication. Ordinary exceptions restore the
+prior cohort; process/power loss is not a multi-file atomicity guarantee. Failed
+rollback retains recovery copies and reports integrity failure explicitly.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import shutil
+import stat
+import sys
+import tempfile
+
+from graphify.paths import os_replace_with_fallback
+
+
+def _same(left: Path, right: Path) -> bool:
+ """Compare bounded chunks rather than duplicating large graph payloads in memory."""
+ if not left.is_file() or not right.is_file() or left.stat().st_size != right.stat().st_size:
+ return False
+ with left.open("rb") as a, right.open("rb") as b:
+ while True:
+ chunk, other = a.read(1_048_576), b.read(1_048_576)
+ if chunk != other:
+ return False
+ if not chunk:
+ return True
+
+
+def _remove_owned(path: Path) -> None:
+ """Only transaction-owned copies may have their Windows read-only bit cleared."""
+ try:
+ path.unlink(missing_ok=True)
+ except PermissionError:
+ if os.name != "nt" or path.is_symlink():
+ raise
+ path.chmod(stat.S_IWRITE)
+ path.unlink(missing_ok=True)
+
+
+class ProductPublication:
+ """One bounded output cohort with explicit stage/commit/rollback ownership.
+
+ The caller supplies names and its replace boundary, preserving existing OS
+ compatibility hooks. Existing serializers write independent stage paths;
+ stages never establish accepted graph/root/manifest/state on their own.
+ """
+
+ def __init__(self, output: Path, names, *, replace=os_replace_with_fallback):
+ self.output, self.replace = Path(output), replace
+ self.targets, self.originals, self.stages = {}, {}, {}
+ self.attempted, self.committed, self.recovery = [], False, False
+ names = tuple(names)
+ if len(names) > 32 or any(not isinstance(name, str) for name in names):
+ raise ValueError("GRAPH_PUBLICATION_FAILED: invalid product inventory")
+ if len(set(names)) != len(names):
+ raise ValueError("GRAPH_PUBLICATION_FAILED: duplicate product inventory")
+ seen = set()
+ for name in names:
+ if (not name or len(name) > 128 or any(char in name for char in "/\\:\0")
+ or Path(name).name != name or name in {".", ".."}):
+ raise ValueError("GRAPH_PUBLICATION_FAILED: invalid product name")
+ try:
+ target = Path(os.path.realpath(self.output / name))
+ except Exception as error:
+ raise OSError("GRAPH_PUBLICATION_FAILED: cannot resolve product destination; prior products retained") from error
+ identity = os.path.normcase(str(target))
+ if identity in seen:
+ raise ValueError("GRAPH_PUBLICATION_FAILED: products share one destination")
+ seen.add(identity)
+ self.targets[name] = target
+ scratch_created = False
+ try:
+ # Setup is part of the publication boundary too. An OS error may
+ # include private paths, and a partial setup owns only its scratch.
+ self.output.mkdir(parents=True, exist_ok=True)
+ self.scratch = Path(tempfile.mkdtemp(dir=self.output, prefix=".gfy-publish-"))
+ scratch_created = True
+ self.staging, self.snapshot, self.restore = (self.scratch / part for part in ("stage", "old", "restore"))
+ for folder in (self.staging, self.snapshot, self.restore):
+ folder.mkdir()
+ for name, target in self.targets.items():
+ original = self.snapshot / name
+ if target.exists():
+ if not target.is_file():
+ raise OSError("GRAPH_PUBLICATION_FAILED: destination is not a file")
+ shutil.copy2(target, original)
+ self.originals[name] = original
+ else:
+ self.originals[name] = None
+ except BaseException as error:
+ if scratch_created:
+ try:
+ self._cleanup()
+ except Exception as cleanup_error:
+ raise OSError("GRAPH_PUBLICATION_CLEANUP: prior products retained; setup cleanup retry required") from cleanup_error
+ if isinstance(error, Exception):
+ raise OSError("GRAPH_PUBLICATION_FAILED: cannot prepare product transaction; prior products retained") from error
+ raise
+
+ def __enter__(self):
+ return self
+
+ def stage(self, name: str) -> Path:
+ """Seed prior bytes for existing serializers without touching accepted products."""
+ if name not in self.targets:
+ raise ValueError("GRAPH_PUBLICATION_FAILED: unknown product")
+ if name not in self.stages:
+ staged = self.staging / name
+ original = self.originals[name]
+ if original is not None:
+ shutil.copy2(original, staged)
+ # Own staging is writable even when its original is not. The
+ # real destination is checked only if publication changes it.
+ staged.chmod(staged.stat().st_mode | stat.S_IWRITE)
+ self.stages[name] = staged
+ return self.stages[name]
+
+ def commit(self) -> tuple[str, ...]:
+ """Publish prepared changes, then mark one coherent cohort committed."""
+ changed = []
+ # Even an unchanged stage must agree with its snapshot: another writer
+ # cannot silently change one member while this run advances the rest.
+ for name, target in self.targets.items():
+ original = self.originals[name]
+ if original is not None and not _same(target, original):
+ raise OSError("GRAPH_PUBLICATION_FAILED: destination changed during preparation")
+ if original is None and target.exists():
+ raise OSError("GRAPH_PUBLICATION_FAILED: destination appeared during preparation")
+ for name, stage in self.stages.items():
+ target, original = self.targets[name], self.originals[name]
+ if not stage.is_file():
+ raise OSError("GRAPH_PUBLICATION_FAILED: staged product is missing")
+ if original is not None and _same(stage, original):
+ continue
+ if original is not None:
+ mode = target.stat().st_mode
+ if os.name == "nt" and not mode & stat.S_IWRITE:
+ raise PermissionError("GRAPH_PUBLICATION_FAILED: read-only " + name)
+ stage.chmod(stat.S_IMODE(mode))
+ changed.append(name)
+ try:
+ for name in changed:
+ self.targets[name].parent.mkdir(parents=True, exist_ok=True)
+ self.attempted.append(name)
+ self.replace(self.stages[name], self.targets[name])
+ except BaseException:
+ self._rollback()
+ raise
+ self.committed = True
+ return tuple(changed)
+
+ def _rollback(self) -> None:
+ """Restore durable originals; a second fault keeps all remaining recovery copies."""
+ # Retain recovery evidence until EVERY restoration finishes. Ordinary
+ # non-OS faults and an interrupted rollback must not trigger cleanup.
+ self.recovery = True
+ failed = False
+ for name in reversed(self.attempted):
+ target, original = self.targets[name], self.originals[name]
+ try:
+ if original is None:
+ _remove_owned(target)
+ elif not _same(target, original):
+ # Copy rather than consume recovery evidence until every
+ # restored target has completed and can be read back.
+ restore = self.restore / name
+ shutil.copy2(original, restore)
+ os_replace_with_fallback(restore, target)
+ if not _same(target, original):
+ raise OSError("Restored product does not match its snapshot")
+ except Exception:
+ failed = True
+ if failed:
+ raise OSError("GRAPH_PUBLICATION_RECOVERY: rollback incomplete; recovery copies retained")
+ self.recovery = False
+
+ def _cleanup(self) -> None:
+ """Remove only known copies; unknown/locked files are retained for diagnosis."""
+ for part in ("stage", "old", "restore"):
+ # Reconstruct owned children so partially completed setup does not
+ # require every stage attribute to have been assigned already.
+ folder = self.scratch / part
+ for name in self.targets:
+ _remove_owned(folder / name)
+ if folder.exists():
+ folder.rmdir()
+ self.scratch.rmdir()
+
+ def __exit__(self, kind, error, traceback):
+ if self.recovery:
+ return False
+ try:
+ self._cleanup()
+ except Exception:
+ if self.committed:
+ # Publication succeeded. Cleanup cannot retroactively report
+ # a failed graph operation after its cohort became authoritative.
+ print("warning: GRAPH_PUBLICATION_CLEANUP: committed products retained; cleanup retry required",
+ file=sys.stderr)
+ else:
+ raise OSError("GRAPH_PUBLICATION_CLEANUP: prior products retained; cleanup retry required") from error
+ if isinstance(error, Exception) and not self.committed:
+ # Do not expose serializer bodies or filesystem paths in the new
+ # diagnostic. Chaining retains the precise internal failure cause.
+ raise OSError("GRAPH_PUBLICATION_FAILED: product preparation or publication failed; prior products retained") from error
+ return False
diff --git a/graphify/qml_module_index.py b/graphify/qml_module_index.py
new file mode 100644
index 0000000000..b11c291950
--- /dev/null
+++ b/graphify/qml_module_index.py
@@ -0,0 +1,225 @@
+"""Per-run module/directory indexes over accepted facts; never widen the corpus."""
+
+from __future__ import annotations
+
+import posixpath
+from pathlib import Path
+
+from graphify.qml_resolution_types import Resolution, answer, qml_metadata, relative_reference, source_path
+
+MAX_MODULE_RESOLUTION_STEPS = 1024
+
+
+class QmlModuleIndex:
+ """Own lookup tables, borrow declarations read-only, and preserve provider identity."""
+
+ def __init__(self, nodes, edges, *, root: Path, import_roots=None, native_index=None, project_index=None):
+ self.root = Path(root).resolve()
+ self.native_index, self.project_index = native_index, project_index
+ self.import_roots = tuple(import_roots if import_roots is not None else (".",))
+ self.nodes = {}
+ self.paths = {}
+ self.by_file = {}
+ self.parents = {}
+ for node in nodes:
+ path = source_path(node, self.root)
+ if path is not None and node.get("id"):
+ self.nodes[node["id"]] = node
+ self.paths[node["id"]] = path
+ self.by_file.setdefault(path, []).append(node["id"])
+ for edge in edges:
+ if edge.get("relation") == "contains":
+ self.parents.setdefault(edge["target"], set()).add(edge["source"])
+ self.components = {}
+ self.imports = {}
+ self.providers = {}
+ self.exports = {}
+ for nid, node in self.nodes.items():
+ md, path = qml_metadata(node), self.paths[nid]
+ kind = md.get("kind")
+ if kind == "component":
+ self.components.setdefault(path, []).append(nid)
+ elif kind == "import":
+ self.imports.setdefault(path, []).append(nid)
+ elif kind == "module" or (kind == "file" and md.get("metadata_format") == "qmldir"):
+ self.providers.setdefault(posixpath.dirname(path), []).append(nid)
+ elif kind == "export":
+ self.exports.setdefault(posixpath.dirname(path), []).append(nid)
+ # A module declaration supersedes its containing qmldir file as provider.
+ for directory, ids in self.providers.items():
+ modules = [nid for nid in ids if qml_metadata(self.nodes[nid]).get("kind") == "module"]
+ self.providers[directory] = sorted(modules or ids)
+
+ def md(self, node_or_id):
+ """Native lookup views are read-only; generic C++ nodes stay unchanged."""
+ node = self.nodes[node_or_id] if isinstance(node_or_id, str) else node_or_id
+ native = self.native_index.metadata(node["id"]) if self.native_index else {}
+ return native or qml_metadata(node)
+
+ def _target(self, export: str, *, importer: str) -> Resolution:
+ node, md = self.nodes[export], qml_metadata(self.nodes[export])
+ source = self.paths[export]
+ if md.get("internal") and posixpath.dirname(importer) != posixpath.dirname(source):
+ return Resolution("unavailable", reason="internal_export", evidence=(export,))
+ target = relative_reference(source, str(md.get("target_path") or ""))
+ if target is None or target not in self.by_file:
+ return Resolution("unavailable", reason="target_outside_corpus", evidence=(export,))
+ candidates = self.components.get(target, [])
+ if md.get("export_kind") == "script":
+ candidates = [nid for nid in self.by_file[target]
+ if self.nodes[nid].get("label") == posixpath.basename(target)]
+ if md.get("singleton") and candidates:
+ if not all(qml_metadata(self.nodes[nid]).get("singleton") for nid in candidates):
+ return Resolution("unavailable", reason="singleton_pragma_missing", evidence=(export,))
+ return answer(candidates, reason="component_unavailable", evidence=(export,))
+
+ def directory_type(self, directory: str, name: str, *, importer: str) -> Resolution:
+ exports = self.exports.get(directory)
+ if exports is not None:
+ matches = [nid for nid in exports if qml_metadata(self.nodes[nid]).get("raw_name") == name and
+ qml_metadata(self.nodes[nid]).get("export_kind") == "type"]
+ versions = [(qml_metadata(self.nodes[nid]).get("major"),
+ qml_metadata(self.nodes[nid]).get("minor")) for nid in matches]
+ versioned = [version for version in versions if version[0] is not None]
+ if versioned:
+ latest = max(versioned)
+ matches = [nid for nid, version in zip(matches, versions) if version == latest]
+ results = [self._target(nid, importer=importer) for nid in matches]
+ return combine(results, reason="type_unavailable")
+ candidates = []
+ for path, ids in self.components.items():
+ if posixpath.dirname(path) == directory and posixpath.basename(path) == name + ".qml":
+ candidates.extend(ids)
+ return answer(candidates, reason="type_unavailable")
+
+ def module_provider(self, uri: str, major=None, minor=None) -> Resolution:
+ candidates = []
+ for rank, import_root in enumerate(self.import_roots):
+ prefix = posixpath.normpath(str(import_root).replace("\\", "/"))
+ if prefix.startswith("/") or prefix == ".." or prefix.startswith("../") or ":" in prefix:
+ continue
+ expected = posixpath.normpath(posixpath.join(prefix, uri.replace(".", "/")))
+ for directory, ids in self.providers.items():
+ suffix = directory[len(expected):] if directory.startswith(expected) else "invalid"
+ if suffix not in {"", f".{major}", f".{major}.{minor}"}:
+ continue
+ for nid in ids:
+ if qml_metadata(self.nodes[nid]).get("uri") != uri:
+ continue
+ # Explicit root order and exact versioned layout are evidence.
+ priority = (rank, 0 if minor is not None and suffix == f".{major}.{minor}" else
+ 1 if major is not None and suffix == f".{major}" else 2)
+ candidates.append((priority, nid))
+ if not candidates:
+ return Resolution("unavailable", reason="module_not_in_import_roots")
+ priority = min(item[0] for item in candidates)
+ return answer([nid for key, nid in candidates if key == priority], reason="module_unavailable")
+
+ def module_import(self, uri: str, major=None, minor=None) -> Resolution:
+ """Require an observed available module version before selecting its types."""
+ provider = self.module_provider(uri, major, minor)
+ if provider.status != "resolved":
+ return provider
+ selected, _, _ = self._module_version(provider, major, minor)
+ return selected
+
+ def _module_version(self, provider: Resolution, major, minor):
+ if provider.target_id is None:
+ return provider, major, minor
+ source = self.paths[provider.target_id]
+ directory = posixpath.dirname(source)
+ records = self.exports.get(directory, [])
+ versions = set()
+ for nid in records:
+ md = qml_metadata(self.nodes[nid])
+ ma, mi = md.get("major"), md.get("minor")
+ if isinstance(ma, int) and isinstance(mi, int):
+ versions.add((ma, mi))
+ if major is not None and not versions:
+ return Resolution("unavailable", reason="module_version_unavailable"), major, minor
+ if major is None and versions:
+ major, minor = max(versions)
+ elif major is not None and minor is None:
+ minors = [mi for ma, mi in versions if ma == major]
+ if not minors:
+ return Resolution("unavailable", reason="module_version_unavailable"), major, minor
+ minor = max(minors)
+ if versions and (major, minor) not in versions:
+ return Resolution("unavailable", reason="module_version_unavailable"), major, minor
+ return provider, major, minor
+
+ def module_type(self, uri: str, major, minor, name: str, *, importer: str, seen=(), _budget=None,
+ _export_kind="type") -> Resolution:
+ # One query owns the work budget across branches, including repeated DAG paths.
+ budget = [MAX_MODULE_RESOLUTION_STEPS] if _budget is None else _budget
+ budget[0] -= 1
+ if budget[0] < 0:
+ return Resolution("unsupported", reason="module_resolution_work_limit")
+ key = (uri, major, minor)
+ if key in seen or len(seen) >= 32:
+ return Resolution("unsupported", reason="module_import_cycle_or_limit")
+ provider = QmlModuleIndex.module_import(self, uri, major, minor)
+ if provider.target_id is None:
+ return provider
+ _, major, minor = self._module_version(provider, major, minor)
+ source = self.paths[provider.target_id]
+ directory = posixpath.dirname(source)
+ records = self.exports.get(directory, [])
+ related = [self.nodes[nid] for nid in self.by_file[source]]
+ if any(qml_metadata(node).get("kind") == "prefer" for node in related):
+ return Resolution("unsupported", reason="preferred_path_requires_resource_index")
+ matches = []
+ for nid in records:
+ md = qml_metadata(self.nodes[nid])
+ version = (md.get("major"), md.get("minor"))
+ if md.get("export_kind") == _export_kind and md.get("raw_name") == name and (version[0] is None or
+ (version[0] == major and isinstance(version[1], int) and
+ isinstance(minor, int) and version[1] <= minor)):
+ matches.append(((-1, -1) if version[0] is None else version, nid))
+ if matches:
+ best = max(version for version, _ in matches)
+ result = combine([self._target(nid, importer=importer) for version, nid in matches if version == best])
+ return Resolution(result.status, result.target_id, result.reason,
+ tuple(sorted(set(result.evidence + (provider.target_id,)))), result.candidates)
+ imports = [qml_metadata(node) for node in related if qml_metadata(node).get("kind") == "module_import"]
+ return combine([self.module_type(md["uri"], major if md.get("auto") else md.get("major"),
+ minor if md.get("auto") else md.get("minor"), name,
+ importer=importer, seen=seen + (key,), _budget=budget,
+ _export_kind=_export_kind) for md in imports])
+
+ def module_script(self, uri: str, major, minor, name: str, *, importer: str) -> Resolution:
+ """Script namespace exports have a separate lookup role from object types."""
+ return self.module_type(uri, major, minor, name, importer=importer, _export_kind="script")
+
+ def directory_script(self, directory: str | None, name: str, *, importer: str) -> Resolution:
+ """Only observed qmldir script exports enter a directory import namespace."""
+ matches = [nid for nid in self.exports.get(directory, [])
+ if qml_metadata(self.nodes[nid]).get("export_kind") == "script"
+ and qml_metadata(self.nodes[nid]).get("raw_name") == name]
+ versioned = [(qml_metadata(self.nodes[nid]).get("major"),
+ qml_metadata(self.nodes[nid]).get("minor")) for nid in matches
+ if qml_metadata(self.nodes[nid]).get("major") is not None]
+ if versioned:
+ latest = max(versioned)
+ matches = [nid for nid in matches if (qml_metadata(self.nodes[nid]).get("major"),
+ qml_metadata(self.nodes[nid]).get("minor")) == latest]
+ return combine([self._target(nid, importer=importer) for nid in matches], reason="script_namespace_unavailable")
+
+
+def combine(results, *, reason="type_unavailable") -> Resolution:
+ """Preserve ambiguity even when a competing result also has a unique endpoint."""
+ results = list(results)
+ blocked = [result for result in results if result.status == "unsupported"]
+ if blocked:
+ return blocked[0]
+ ids, evidence = [], []
+ for result in results:
+ if result.target_id:
+ ids.append(result.target_id)
+ ids.extend(result.candidates)
+ evidence.extend(result.evidence)
+ if ids:
+ return answer(ids, reason=reason, evidence=evidence)
+ if results:
+ return results[0]
+ return Resolution("unavailable", reason=reason)
diff --git a/graphify/qml_projection.py b/graphify/qml_projection.py
new file mode 100644
index 0000000000..b0a82ececb
--- /dev/null
+++ b/graphify/qml_projection.py
@@ -0,0 +1,33 @@
+"""Narrow source-backed exceptions to generic cross-language phantom guards."""
+from pathlib import Path
+from graphify.extractors.qml_facts import qml_metadata
+
+
+def allows_qml_script_edge(source: dict, target: dict, edge: dict, *, target_id: str | None = None) -> bool:
+ """Only dedicated QML sites can bridge to an accepted JavaScript resource.
+
+ A context string or matching label alone is insufficient. The ordinary
+ generic JS graph remains independent from the QML script overlay.
+ """
+ source_qml = source.get("metadata", {}).get("qml", {})
+ target_qml = target.get("metadata", {}).get("qml", {})
+ if source_qml.get("contract_version") != 1 or edge.get("confidence") != "INFERRED":
+ return False
+ if Path(source.get("source_file") or "").suffix.lower() not in {".qml", ".js", ".mjs"}:
+ return False
+ if Path(target.get("source_file") or "").suffix.lower() not in {".js", ".mjs"}:
+ return False
+ if edge.get("relation") == "imports":
+ try:
+ proof = qml_metadata(edge)
+ except ValueError:
+ return False
+ accepted_file = (proof.get("target_role") == "script_file"
+ and proof.get("target_file_id") == (target_id or target.get("id"))
+ and proof.get("target_file") == target.get("source_file"))
+ return (source_qml.get("kind") == "import_resolution" and accepted_file
+ and edge.get("context") == "qml_import_resolution")
+ if edge.get("relation") == "calls":
+ return (source_qml.get("kind") == "call" and target_qml.get("kind") == "qml_script_function"
+ and target_qml.get("contract_version") == 1 and edge.get("context") == "qml_script_call")
+ return False
diff --git a/graphify/qml_relationship_lookup.py b/graphify/qml_relationship_lookup.py
new file mode 100644
index 0000000000..0ce80875ad
--- /dev/null
+++ b/graphify/qml_relationship_lookup.py
@@ -0,0 +1,148 @@
+"""Bounded alias and imported-script lookup over accepted source facts only."""
+from __future__ import annotations
+
+from graphify.qml_resolution_types import Resolution, answer, qml_metadata, relative_reference
+
+
+class RelationshipLookup:
+ """No global labels, value evaluation, runtime IDs or borrowed-node mutation."""
+
+ def __init__(self, index):
+ self.index = index
+ self.aliases, self.functions, self.exports, self.script_imports = {}, {}, {}, {}
+ for nid, node in index.nodes.items():
+ md, path = qml_metadata(node), index.paths[nid]
+ if md.get("kind") == "alias":
+ self.aliases.setdefault(md.get("owner_id"), []).append(nid)
+ elif md.get("kind") == "qml_script_function":
+ self.functions.setdefault((path, md.get("raw_name")), []).append(nid)
+ for name in md.get("export_names", []):
+ self.exports.setdefault((path, name), []).append(nid)
+ elif md.get("kind") == "script_import":
+ self.script_imports.setdefault((path, md.get("qualifier")), []).append(nid)
+
+ def _script_function(self, file, name, *, external):
+ table = self.exports if external else self.functions
+ candidates = [nid for nid in table.get((file, name), [])
+ if qml_metadata(self.index.nodes[nid]).get("static_callable") is not False]
+ return answer(candidates, reason="script_member_not_exported_or_missing")
+
+ def _script(self, file, reference):
+ parts = reference.split(".")
+ if len(parts) == 1:
+ local = self._script_function(file, reference, external=False)
+ if local.status in {"resolved", "ambiguous"}:
+ return local
+ imports = self.script_imports.get((file, parts[0]), [])
+ answers = []
+ for nid in imports:
+ md = qml_metadata(self.index.nodes[nid])
+ target = relative_reference(file, md.get("value") or "")
+ if target is None or target not in self.index.by_file:
+ answers.append(Resolution("unavailable", reason="script_outside_corpus"))
+ elif md.get("import_kind") == "namespace" and len(parts) == 2:
+ answers.append(self._script_function(target, parts[1], external=True))
+ elif md.get("import_kind") == "named" and len(parts) == 1:
+ answers.append(self._script_function(target, md.get("exported_name"), external=True))
+ else:
+ answers.append(Resolution("dynamic", reason="script_namespace_or_value_is_not_callable"))
+ return self._combine(answers, reason="script_runtime_context_unavailable")
+
+ @staticmethod
+ def _combine(results, *, reason):
+ results = list(results)
+ ids, evidence = [], []
+ for result in results:
+ if result.target_id:
+ ids.append(result.target_id)
+ ids.extend(result.candidates)
+ evidence.extend(result.evidence)
+ selected = answer(ids, reason=reason, evidence=evidence)
+ if selected.status != "unavailable":
+ return selected
+ return results[0] if results else selected
+
+ def _qml_script(self, file, component, reference):
+ parts, results = reference.split("."), []
+ if len(parts) == 2:
+ for nid in self.index.imports.get(file, []):
+ md = qml_metadata(self.index.nodes[nid])
+ if md.get("import_kind") != "script" or md.get("qualifier") != parts[0]:
+ continue
+ target = relative_reference(file, md.get("value") or "")
+ result = self._script_function(target, parts[1], external=True)
+ results.append(Resolution(result.status, result.target_id, result.reason,
+ result.evidence + (nid,), result.candidates))
+ # Script namespaces and object types are distinct qmldir export roles.
+ for nid in self.index.imports.get(file, []):
+ md = qml_metadata(self.index.nodes[nid])
+ qualifier = md.get("qualifier") or ""
+ wanted = parts[1:] if qualifier and parts[0] == qualifier else parts if not qualifier else []
+ if len(wanted) != 2:
+ continue
+ if md.get("import_kind") == "module":
+ provider = self.index.module_script(md.get("value"), md.get("major"), md.get("minor"), wanted[0], importer=file)
+ elif md.get("import_kind") == "directory":
+ directory = relative_reference(file, md.get("value") or "")
+ provider = self.index.directory_script(directory, wanted[0], importer=file)
+ else:
+ continue
+ if provider.status == "resolved":
+ target = self.index.paths[provider.target_id]
+ result = self._script_function(target, wanted[1], external=True)
+ results.append(Resolution(result.status, result.target_id, result.reason,
+ result.evidence + provider.evidence + (nid,), result.candidates))
+ elif provider.status == "ambiguous":
+ results.append(provider)
+ return self._combine(results, reason="script_import_not_visible")
+
+ def chase_alias(self, result, seen=()):
+ if result.status != "resolved" or result.target_id not in self.aliases:
+ return result
+ if result.target_id in seen or len(seen) >= 32:
+ return Resolution("unsupported", reason="alias_cycle_or_limit")
+ sites = self.aliases[result.target_id]
+ if len(sites) != 1:
+ return Resolution("ambiguous", reason="duplicate_alias_binding", candidates=tuple(sites[:50]))
+ site = self.index.nodes[sites[0]]
+ md = qml_metadata(site)
+ resolved = self.resolve(site, md.get("reference") or "", seen=seen + (result.target_id,))
+ return Resolution(resolved.status, resolved.target_id, resolved.reason,
+ tuple(dict.fromkeys(result.evidence + (site["id"],) + resolved.evidence)), resolved.candidates)
+
+ def resolve(self, site, reference, *, seen=()):
+ md, file = qml_metadata(site), self.index.paths[site["id"]]
+ if not reference:
+ return Resolution("dynamic", reason=md.get("reason") or "computed_or_runtime_target")
+ parts = reference.split(".")
+ lexical = md.get("lexical_names") or []
+ if md.get("lexical_shadowed") or parts[0] in lexical:
+ target = md.get("lexical_target_id")
+ if target and len(parts) == 1 and target in self.index.nodes:
+ return answer([target])
+ return Resolution("dynamic", reason="javascript_lexical_binding")
+ if md.get("script_file"):
+ return self._script(file, reference)
+ component, scope = md.get("component_key"), md.get("object_scope_key")
+ # Local QML names shadow an imported JS qualifier, even when they cannot
+ # be continued statically. A computed namespace never bypasses shadowing.
+ head = self.index.resolve_member(file, component, scope, parts[0])
+ if head.status == "ambiguous":
+ return head
+ if head.status == "resolved":
+ # Follow each established prefix separately so aliases inside a chain
+ # can be chased without pretending an alias's raw type is a provider.
+ result = self.chase_alias(head, seen)
+ for member in parts[1:]:
+ if result.status != "resolved":
+ return result
+ evidence = result.evidence
+ result = self.chase_alias(self.index.follow_member(result.target_id, [member]), seen)
+ result = Resolution(result.status, result.target_id, result.reason,
+ tuple(dict.fromkeys(evidence + result.evidence)), result.candidates)
+ return result
+ script = self._qml_script(file, component, reference)
+ if script.status in {"resolved", "ambiguous"} or script.evidence:
+ return script
+ result = self.index.resolve_member(file, component, scope, reference, lexical_names=lexical)
+ return self.chase_alias(result, seen)
diff --git a/graphify/qml_relationships.py b/graphify/qml_relationships.py
new file mode 100644
index 0000000000..201509783d
--- /dev/null
+++ b/graphify/qml_relationships.py
@@ -0,0 +1,152 @@
+"""Project expression sites to proven scoped endpoints without runtime execution."""
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+
+from graphify.extractors.qml_facts import encode_metadata
+from graphify.qml_relationship_lookup import RelationshipLookup
+from graphify.qml_resolution import build_qml_index
+from graphify.qml_resolution_types import Resolution, fact_edge, fact_node, qml_metadata
+
+
+def _handler(lookup, site):
+ md, index = qml_metadata(site), lookup.index
+ if md.get("disabled_reason"):
+ return Resolution("unsupported", reason=md["disabled_reason"])
+ if md.get("attached_prefix"):
+ return Resolution("unsupported", reason="attached_signal_provider_unavailable")
+ reference = md.get("reference") or ""
+ if md.get("connections"):
+ if not md.get("connection_supplied") or not md.get("connection_target"):
+ return Resolution("dynamic", reason="connections_runtime_target")
+ reference = md["connection_target"] + "." + reference
+ result = lookup.resolve(site, reference)
+ if result.status == "resolved":
+ kind = index.md(result.target_id).get("kind")
+ if kind == "signal":
+ return result
+ return Resolution("unsupported", reason="handler_target_is_not_signal")
+ # QML generates a notify signal for an observed source-declared property.
+ # Keep that inferred signal distinct from the property and its subscriptions.
+ if reference.endswith("Changed"):
+ prop = lookup.resolve(site, reference[:-7])
+ if prop.status == "resolved" and index.md(prop.target_id).get("kind") == "property":
+ metadata = index.md(prop.target_id)
+ if metadata.get("qt_native"):
+ # Qt exposes onChanged even when the actual native
+ # NOTIFY signal has another name. Its source accessor supplies
+ # the endpoint; pure-QML inferred signals remain separate.
+ notify = index.native_index.property_notify(prop.target_id, metadata.get("provider_id"))
+ return Resolution(notify.status, notify.target_id, notify.reason,
+ tuple(dict.fromkeys(prop.evidence + notify.evidence)), notify.candidates)
+ return Resolution("resolved", prop.target_id, "property_notify_signal", prop.evidence)
+ return result
+
+
+def _implicit_handler_parameter(lookup, site, cache):
+ """Resolve inherited signal binders before any same-name QML member fallback."""
+ md, index = qml_metadata(site), lookup.index
+ first = (md.get("reference") or "").split(".")[0]
+ owner, seen = md.get("owner_id"), set()
+ while owner and owner in index.nodes and owner not in seen and len(seen) < 32:
+ seen.add(owner)
+ node = index.nodes[owner]
+ ancestor = qml_metadata(node)
+ if ancestor.get("kind") == "handler":
+ # This authoritative source flag survives bounded display metadata;
+ # explicit function/arrow formals never inherit signal names.
+ if ancestor.get("implicit_parameters") is not True:
+ return False
+ if owner not in cache:
+ signal = _handler(lookup, node)
+ cache[owner] = (index.md(signal.target_id).get("parameter_names") or []
+ if signal.status == "resolved" and signal.target_id in index.nodes else [])
+ return first in cache[owner]
+ owner = ancestor.get("owner_id")
+ return False
+
+
+def resolve_qml_relationships(per_file, all_nodes, all_edges, *, root: Path, import_roots=None, native_index=None, project_index=None) -> None:
+ """Mutate fresh source-owned sites only; prior graph context is read-only.
+
+ Each occurrence is already a unique fact with its expression span and owner.
+ Separate endpoint edges preserve repeated reads/calls through Graph/DiGraph.
+ Unsupported/dynamic/ambiguous sites remain explicit and get no guessed edge.
+ """
+ index = build_qml_index(all_nodes, all_edges, root=root, import_roots=import_roots, native_index=native_index, project_index=project_index)
+ lookup = RelationshipLookup(index)
+ fresh = {node["id"] for result in per_file.values() if result for node in result.get("nodes", [])}
+ node_ids = {node["id"] for node in all_nodes}
+ edge_keys = {(e["source"], e["target"], e["relation"], e.get("context")) for e in all_edges}
+ handler_parameters = {}
+
+ def append_node(node):
+ if node["id"] not in node_ids:
+ all_nodes.append(node)
+ node_ids.add(node["id"])
+
+ def append_edge(edge):
+ key = edge["source"], edge["target"], edge["relation"], edge.get("context")
+ if key not in edge_keys:
+ all_edges.append(edge)
+ edge_keys.add(key)
+
+ for nid in sorted(fresh):
+ site = index.nodes.get(nid)
+ if site is None:
+ continue
+ md = qml_metadata(site)
+ kind = md.get("kind")
+ if kind not in {"read", "call", "alias", "handler"}:
+ continue
+ if kind in {"read", "call"} and not md.get("lexical_shadowed") and _implicit_handler_parameter(lookup, site, handler_parameters):
+ md["lexical_shadowed"] = True
+ md["lexical_target_id"] = ""
+ result = Resolution("dynamic", reason="javascript_lexical_binding")
+ else:
+ result = _handler(lookup, site) if kind == "handler" else lookup.resolve(site, md.get("reference") or "")
+ if result.status == "resolved" and kind == "call":
+ target_kind = index.md(result.target_id).get("kind")
+ if target_kind not in {"function", "js_function", "qml_script_function", "signal"}:
+ result = Resolution("dynamic", reason="runtime_callable_value")
+ span = md.get("span", {})
+ owner = {"source_file": index.paths[nid], "source_location": site.get("source_location", "L1")}
+ target = result.target_id
+ if result.reason == "property_notify_signal":
+ assert target is not None
+ key = hashlib.sha256(f'{target}:notify'.encode()).hexdigest()
+ signal = fact_node(index.paths[target], "property_change_signal", key,
+ (qml_metadata(index.nodes[target]).get("raw_name") or "property") + "Changed",
+ qml_metadata(index.nodes[target]).get("span", {}).get("start_row", 0) + 1,
+ property_id=target, status="inferred", span=qml_metadata(index.nodes[target]).get("span", {}))
+ append_node(signal)
+ append_edge(fact_edge(target, signal["id"], "contains", index.nodes[target], "qml_property_notify_signal"))
+ target = signal["id"]
+ updated = {k: v for k, v in md.items() if k != "raw_values"}
+ updated.update(status=result.status, reason=result.reason, resolved_target_id=target or "",
+ evidence=list(result.evidence[:50]), candidates=list(result.candidates[:50]),
+ diagnostic_code="" if result.status == "resolved" else "QML-EXPRESSION-001")
+ site["metadata"]["qml"] = encode_metadata(updated)
+ if result.status != "resolved":
+ continue
+ assert target is not None
+ relation, context = "uses", "qml_binding_read"
+ if kind == "alias":
+ relation, context = "references", "qml_alias_target"
+ elif kind == "handler":
+ relation, context = "references", "qml_signal_subscription"
+ elif kind == "call":
+ target_kind = index.md(target).get("kind")
+ if target_kind == "signal":
+ context = "qml_signal_emit"
+ else:
+ relation = "calls"
+ context = "qml_script_call" if target_kind == "qml_script_function" else "qml_js_call"
+ edge = fact_edge(nid, target, relation, owner, context, confidence="INFERRED",
+ span=span, evidence=list(result.evidence[:50]))
+ proof = native_index.endpoint_proof(target, result.evidence) if native_index else {}
+ if proof:
+ from graphify.extractors.qt_cpp_facts import encode_qt
+ edge["metadata"]["qt"] = encode_qt({"kind": "qml_bridge", "span": span, "native_endpoint": proof})
+ append_edge(edge)
diff --git a/graphify/qml_resolution.py b/graphify/qml_resolution.py
new file mode 100644
index 0000000000..12065416c3
--- /dev/null
+++ b/graphify/qml_resolution.py
@@ -0,0 +1,115 @@
+"""Project QML lookup results through source-owned sites in Graphify's simple graph."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import posixpath
+from pathlib import Path
+
+from graphify.qml_resolution_types import Resolution, answer, fact_edge, fact_node, qml_metadata, relative_reference
+from graphify.qml_scope import QmlProjectIndex
+
+
+def build_qml_index(nodes, edges, *, root: Path, import_roots=None, native_index=None, project_index=None) -> QmlProjectIndex:
+ """Indexes have one run lifetime and never discover or execute additional files."""
+ return QmlProjectIndex(nodes, edges, root=Path(root), import_roots=import_roots,
+ native_index=native_index, project_index=project_index)
+
+
+def _import_target(index: QmlProjectIndex, node: dict, file: str):
+ md = qml_metadata(node)
+ kind, value = md.get("import_kind"), md.get("value") or ""
+ if kind == "module":
+ return index.module_import(value, md.get("major"), md.get("minor")), None
+ path = relative_reference(file, value)
+ if path is None:
+ return Resolution("unavailable", reason="import_outside_corpus"), None
+ if kind == "script":
+ candidates = [nid for nid in index.by_file.get(path, [])
+ if index.nodes[nid].get("type") == "file" or
+ index.nodes[nid].get("label") == posixpath.basename(path)]
+ return answer(candidates, reason="script_outside_corpus"), None
+ if kind == "directory":
+ known = any(posixpath.dirname(accepted) == path for accepted in index.by_file)
+ if not known:
+ return Resolution("unavailable", reason="directory_outside_corpus"), None
+ namespace = fact_node(file, "directory_namespace", path, value,
+ md.get("span", {}).get("start_row", 0) + 1,
+ directory=path, span=md.get("span", {}))
+ return Resolution("resolved", namespace["id"]), namespace
+ return Resolution("unsupported", reason="unsupported_import_kind"), None
+
+
+def resolve_qml_project(per_file, all_nodes: list[dict], all_edges: list[dict], *,
+ root: Path, import_roots=None, native_index=None, project_index=None) -> None:
+ """Append derived facts only for fresh sources; borrowed context stays immutable.
+
+ Resolved imports/types use independent sites so parallel source mechanisms
+ cannot overwrite each other in a simple graph. Unresolved sites retain a
+ bounded reason/candidate set and never receive a guessed target edge.
+ """
+ index = build_qml_index(all_nodes, all_edges, root=root, import_roots=import_roots,
+ native_index=native_index, project_index=project_index)
+ fresh = {node["id"] for result in per_file.values() for node in result.get("nodes", [])}
+ known_nodes = {node["id"] for node in all_nodes}
+ known_edges = {(edge["source"], edge["target"], edge["relation"], edge.get("context"))
+ for edge in all_edges}
+
+ def append_node(node):
+ if node["id"] not in known_nodes:
+ all_nodes.append(node)
+ known_nodes.add(node["id"])
+
+ def append_edge(edge):
+ key = (edge["source"], edge["target"], edge["relation"], edge.get("context"))
+ if key not in known_edges:
+ all_edges.append(edge)
+ known_edges.add(key)
+
+ for node_id in sorted(fresh):
+ node = index.nodes.get(node_id)
+ if node is None:
+ continue
+ md, file = qml_metadata(node), index.paths[node_id]
+ kind = md.get("kind")
+ namespace = None
+ if kind == "import":
+ result, namespace = _import_target(index, node, file)
+ site_kind, relation, label = "import_resolution", "imports", md.get("value") or "import"
+ elif kind == "object":
+ label = md.get("type_name") or md.get("raw_type") or ""
+ result = index.resolve_type(file, md.get("component_key") or "", label)
+ native = native_index.metadata(result.target_id) if native_index and result.target_id else {}
+ if native.get("qt_native") and (not native.get("creatable") or native.get("singleton")):
+ result = Resolution("unsupported", reason="native_type_not_creatable", evidence=result.evidence)
+ site_kind, relation = "type_use", "uses"
+ else:
+ continue
+ if namespace is not None:
+ append_node(namespace)
+ span = md.get("span", {})
+ line = span.get("start_row", 0) + 1
+ # Hash a bounded semantic key; the owner edge retains the full declaration ID.
+ key = hashlib.sha256(json.dumps([node_id, site_kind], ensure_ascii=False).encode()).hexdigest()
+ site = fact_node(file, site_kind, key, label, line, span=span,
+ status=result.status, reason=result.reason,
+ diagnostic_code="QML-RESOLVE-001" if result.status != "resolved" else "",
+ evidence=list(result.evidence[:50]), candidates=list(result.candidates[:50]))
+ append_node(site)
+ owner = {"source_file": file, "source_location": node.get("source_location", f"L{line}")}
+ append_edge(fact_edge(node_id, site["id"], "contains", owner, "qml_resolution_site", span=span))
+ if result.target_id is not None:
+ target_proof = {}
+ if kind == "import" and md.get("import_kind") == "script":
+ target_proof = {"target_role": "script_file", "target_file_id": result.target_id,
+ "target_file": index.paths[result.target_id]}
+ edge = fact_edge(site["id"], result.target_id, relation, owner,
+ "qml_import_resolution" if kind == "import" else "qml_type_resolution",
+ confidence="INFERRED", span=span, evidence=list(result.evidence[:50]), **target_proof)
+ proof = native_index.endpoint_proof(result.target_id, result.evidence,
+ kind="module" if kind == "import" else "component") if native_index else {}
+ if proof:
+ from graphify.extractors.qt_cpp_facts import encode_qt
+ edge["metadata"]["qt"] = encode_qt({"kind": "qml_bridge", "span": span, "native_endpoint": proof})
+ append_edge(edge)
diff --git a/graphify/qml_resolution_types.py b/graphify/qml_resolution_types.py
new file mode 100644
index 0000000000..f48d79245b
--- /dev/null
+++ b/graphify/qml_resolution_types.py
@@ -0,0 +1,98 @@
+"""Portable Qt/QML fact and resolution values; no corpus discovery or execution."""
+
+from __future__ import annotations
+
+import json
+import posixpath
+from dataclasses import dataclass
+from pathlib import Path
+
+from graphify.extractors.qml_facts import encode_metadata, make_qml_id
+from graphify.extractors.qml_facts import qml_metadata as literal_qml_metadata
+
+
+@dataclass(frozen=True)
+class Resolution:
+ """One scoped answer, including why a concrete endpoint was not selected."""
+
+ status: str
+ target_id: str | None = None
+ reason: str = ""
+ evidence: tuple[str, ...] = ()
+ candidates: tuple[str, ...] = ()
+
+ def __post_init__(self):
+ if (self.status == "resolved") != (self.target_id is not None):
+ raise ValueError("QML_RESOLUTION: target must exist exactly for resolved answers")
+
+
+def answer(candidates, *, reason="missing", evidence=()) -> Resolution:
+ """Deduplicate exact declarations; never choose an arbitrary competing target."""
+ ids = tuple(sorted(set(candidates)))
+ if len(ids) == 1:
+ return Resolution("resolved", ids[0], evidence=tuple(sorted(set(evidence))))
+ if ids:
+ return Resolution("ambiguous", reason="duplicate_provider", candidates=ids[:50])
+ return Resolution("unavailable", reason=reason, evidence=tuple(evidence))
+
+
+def qml_metadata(node: dict) -> dict:
+ value = literal_qml_metadata(node)
+ return value if value.get("contract_version") == 1 else {}
+
+
+def source_path(node: dict, root: Path) -> str | None:
+ """Normalize an accepted fact's provenance without reading another source file."""
+ raw = str(node.get("source_file") or "").replace("\\", "/")
+ if not raw:
+ return None
+ path = Path(raw)
+ if path.is_absolute():
+ try:
+ raw = path.relative_to(root).as_posix()
+ except ValueError:
+ return None
+ raw = posixpath.normpath(raw)
+ if raw == ".." or raw.startswith("../") or raw.startswith("/") or ":" in raw:
+ return None
+ return raw
+
+
+def relative_reference(source: str, value: str) -> str | None:
+ """Resolve only a local literal path; corpus membership is checked separately."""
+ value = value.replace("\\", "/")
+ if not value or value.startswith("/") or ":" in value:
+ return None
+ result = posixpath.normpath(posixpath.join(posixpath.dirname(source), value))
+ return None if result == ".." or result.startswith("../") else result
+
+
+def fact_node(source: str, kind: str, key, label: str, line: int, **fields) -> dict:
+ """Mint exact-spelling salted IDs before Graphify's casefold normalization."""
+ semantic_key = json.dumps(key, ensure_ascii=False, separators=(",", ":"))
+ return {
+ "id": make_qml_id(source, kind, semantic_key),
+ "label": label,
+ "file_type": "code",
+ "type": "file" if kind == "file" else "namespace",
+ "source_file": source,
+ "source_location": f"L{line}",
+ "_origin": "ast",
+ "metadata": {"qml": encode_metadata({"kind": kind, **fields})},
+ }
+
+
+def fact_edge(source: str, target: str, relation: str, owner: dict, context: str,
+ *, confidence: str = "EXTRACTED", **metadata) -> dict:
+ return {
+ "source": source,
+ "target": target,
+ "_src": source,
+ "_tgt": target,
+ "relation": relation,
+ "confidence": confidence,
+ "source_file": owner["source_file"],
+ "source_location": owner.get("source_location", "L1"),
+ "context": context,
+ "metadata": {"qml": encode_metadata(metadata)},
+ }
diff --git a/graphify/qml_safety.py b/graphify/qml_safety.py
new file mode 100644
index 0000000000..4473c1196d
--- /dev/null
+++ b/graphify/qml_safety.py
@@ -0,0 +1,114 @@
+"""Guard publication of Qt/QML extraction and conservative incremental refresh.
+
+The graph writer owns persistence. These read-only checks run before graph
+reconciliation or publication: a count-based shrink guard cannot detect lost
+edges, replacement by unrelated nodes, or incomplete QML component scopes.
+"""
+from __future__ import annotations
+
+import json
+from collections.abc import Iterable
+from pathlib import Path
+
+
+class QmlSafetyError(RuntimeError):
+ """The candidate lacks authoritative Qt/QML facts and cannot be published."""
+
+
+def is_qml_path(path: str | Path) -> bool:
+ """Recognize QML source and its named/type-description metadata."""
+ p = Path(path)
+ return p.suffix.lower() in {".qml", ".qmltypes", ".cmake", ".pro", ".pri", ".qrc"} or p.name in {"qmldir", "CMakeLists.txt"}
+
+
+def qml_refresh_required(
+ corpus_paths: Iterable[str | Path], changed_paths: Iterable[str | Path]
+) -> bool:
+ """Refresh the live code corpus when QML or its JS inputs may change scope.
+
+ Until dependency-directed invalidation exists, include unchanged sources
+ rather than reuse project-context edges from an obsolete component scope.
+ Deleted/renamed QML paths count even when the live corpus has none left.
+ """
+ from graphify.qt_incremental import plan_qt_refresh
+ return plan_qt_refresh(corpus_paths, changed_paths).required
+
+
+def require_qml_watch_root(
+ paths: Iterable[str | Path], *, project_root: Path, watch_root: Path
+) -> None:
+ """Reject the legacy subfolder path-only rebase for nested QML fact IDs."""
+ if any(is_qml_path(p) for p in paths) and project_root != watch_root:
+ raise QmlSafetyError(
+ "QML_ROOT_MISMATCH: Qt/QML subfolder updates cannot rebase scoped "
+ "fact IDs safely. Existing graph and manifest were preserved. "
+ "Run graphify update with the absolute project scan root."
+ )
+
+
+def _identity(path: str | Path, root: Path | None) -> str:
+ p = Path(path)
+ if root is not None and not p.is_absolute():
+ p = root / p
+ try:
+ return p.resolve().as_posix()
+ except (OSError, RuntimeError):
+ return p.as_posix()
+
+
+def _display(path: str | Path, root: Path | None) -> str:
+ """Bound diagnostics and escape delimiters without disclosing foreign roots."""
+ p = Path(path)
+ if root is not None and p.is_absolute():
+ try:
+ p = p.relative_to(root)
+ except ValueError:
+ p = Path(p.name)
+ elif p.is_absolute():
+ p = Path(p.name)
+ return json.dumps(p.as_posix()[:160], ensure_ascii=True)
+
+
+def require_complete_qml(
+ result: dict, paths: Iterable[str | Path], *, operation: str,
+ root: Path | None = None,
+) -> None:
+ """Reject failed, partial, or omitted QML contributions before any commit.
+
+ Error paths are authoritative even if unrelated additions conceal a loss.
+ No force/partial flag bypasses this contract. Adapter diagnostics remain
+ separate; this bounded message owns graph retention and the retry action.
+ """
+ qml_paths = [Path(p) for p in paths if is_qml_path(p)]
+ failures = [
+ Path(p) for p in result.get("failed_sources", []) if is_qml_path(p)
+ ]
+ for failure in [*(result.get("qml_failures", []) or []), *(result.get("qt_failures", []) or [])]:
+ if isinstance(failure, dict) and failure.get("source_file"):
+ failures.append(Path(failure["source_file"]))
+ unsafe = bool(result.get("qml_failures") or result.get("qt_failures")) or (
+ bool(qml_paths) and bool(
+ result.get("error") or result.get("partial") or result.get("parse_errors")
+ )
+ )
+ # Every dispatched source needs an owned contribution; the adapter reports
+ # incomplete syntax separately. An omitted source cannot become a successful
+ # empty result merely because its failure marker was lost by a producer.
+ represented = {
+ _identity(n["source_file"], root)
+ for n in result.get("nodes", [])
+ if isinstance(n, dict) and n.get("source_file")
+ }
+ failures.extend(p for p in qml_paths if _identity(p, root) not in represented)
+ if not failures and not unsafe:
+ return
+ shown = sorted({_display(p, root) for p in failures or qml_paths})
+ names = ", ".join(shown[:6])
+ if len(shown) > 6:
+ names += f" (+{len(shown) - 6} more)"
+ raise QmlSafetyError(
+ f"QML_GRAPH_PRESERVED: {operation} rejected incomplete Qt/QML extraction"
+ f"{': ' + names if names else ''}. Existing graph and manifest were "
+ "preserved. Fix the reported parser/source failure and retry; force "
+ "and partial-output flags do not bypass this QML integrity gate."
+ )
diff --git a/graphify/qml_scope.py b/graphify/qml_scope.py
new file mode 100644
index 0000000000..ec8965507e
--- /dev/null
+++ b/graphify/qml_scope.py
@@ -0,0 +1,208 @@
+"""Component/object/member lookup; dynamic instance ancestry is intentionally absent."""
+
+from __future__ import annotations
+
+import posixpath
+
+from graphify.qml_module_index import QmlModuleIndex, combine
+from graphify.qml_resolution_types import Resolution, answer, qml_metadata, relative_reference
+
+
+class QmlProjectIndex(QmlModuleIndex):
+ """Module imports and lexical declarations share one bounded, per-run owner."""
+
+ def __init__(self, nodes, edges, **kwargs):
+ super().__init__(nodes, edges, **kwargs)
+ self.objects, self.members, self.inline = {}, {}, {}
+ for nid, node in self.nodes.items():
+ md, path = qml_metadata(node), self.paths[nid]
+ component = md.get("component_key", "")
+ scope = md.get("object_scope_key", "")
+ if md.get("kind") == "object":
+ self.objects.setdefault((path, component, scope), []).append(nid)
+ elif md.get("kind") in {"property", "function", "signal", "enum"}:
+ self.members.setdefault((path, component, scope, md.get("raw_name")), []).append(nid)
+ elif md.get("kind") == "inline_component":
+ enclosing = md.get("enclosing_component_key") or md.get("parent_component_key")
+ if enclosing is None:
+ owners = [qml_metadata(self.nodes[parent]).get("component_key")
+ for parent in self.parents.get(nid, ()) if parent in self.nodes]
+ enclosing = owners[0] if len(set(owners)) == 1 else None
+ self.inline.setdefault((path, enclosing, md.get("raw_name")), []).append(nid)
+
+ def module_import(self, uri, major=None, minor=None):
+ results = [super().module_import(uri, major, minor)]
+ if self.native_index:
+ results.append(self.native_index.module_import(uri, major, minor))
+ if self.project_index:
+ results.append(self.project_index.module_import(uri, major, minor))
+ # One build namespace and its native registrations describe the same
+ # module when the exact context node is carried in native evidence.
+ resolved = [result for result in results if result.target_id]
+ if len(resolved) > 1:
+ namespaces = [result for result in resolved if any(result.target_id in other.evidence
+ for other in resolved if other is not result)]
+ if len(namespaces) == 1:
+ return Resolution("resolved", namespaces[0].target_id,
+ evidence=tuple(sorted({item for result in resolved for item in result.evidence})))
+ return combine(results)
+
+ def module_type(self, uri, major, minor, name, *, importer, **kwargs):
+ results = [super().module_type(uri, major, minor, name, importer=importer, **kwargs)]
+ if kwargs.get("_export_kind", "type") == "type":
+ if self.native_index:
+ results.append(self.native_index.module_type(uri, major, minor, name, importer=importer))
+ if self.project_index:
+ results.append(self.project_index.resolve_component(uri, name, major, minor))
+ return combine(results)
+
+ def resolve_type(self, file: str, component_key: str, name: str) -> Resolution:
+ """Inline names shadow imports; an import qualifier is document-local."""
+ parts = name.split(".")
+ local = self.inline.get((file, component_key, name), [])
+ if local:
+ return answer(local)
+ results = []
+ for import_id in self.imports.get(file, []):
+ md = qml_metadata(self.nodes[import_id])
+ qualifier = md.get("qualifier") or ""
+ if qualifier:
+ if len(parts) != 2 or parts[0] != qualifier:
+ continue
+ wanted = parts[1]
+ elif len(parts) == 1:
+ wanted = name
+ else:
+ continue
+ value = str(md.get("value") or "")
+ kind = md.get("import_kind")
+ if kind == "module":
+ result = self.module_type(value, md.get("major"), md.get("minor"), wanted, importer=file)
+ elif kind == "directory":
+ directory = relative_reference(file, value)
+ result = (self.directory_type(directory, wanted, importer=file) if directory is not None else
+ Resolution("unavailable", reason="directory_outside_corpus"))
+ else:
+ continue
+ if result.status == "resolved":
+ result = Resolution("resolved", result.target_id, evidence=result.evidence + (import_id,))
+ results.append(result)
+ if len(parts) == 1:
+ # Implicit local-directory visibility cannot become global basename search.
+ results.append(self.directory_type(posixpath.dirname(file), name, importer=file))
+ return combine(results)
+
+ def _root_objects(self, component_id: str) -> list[str]:
+ if self.native_index and self.native_index.is_provider(component_id):
+ return [component_id]
+ md = qml_metadata(self.nodes[component_id])
+ component = md.get("component_key")
+ file = self.paths[component_id]
+ candidates = []
+ for (path, key, _), ids in self.objects.items():
+ if path != file or key != component:
+ continue
+ for nid in ids:
+ if component_id in self.parents.get(nid, ()) or not qml_metadata(self.nodes[nid]).get("parent_scope_key"):
+ candidates.append(nid)
+ return sorted(candidates)
+
+ def _object_member(self, object_id: str, name: str, seen=()) -> Resolution:
+ if self.native_index and self.native_index.is_provider(object_id):
+ return self.native_index.member(object_id, name)
+ if object_id in seen or len(seen) >= 32:
+ return Resolution("unsupported", reason="inheritance_cycle_or_limit")
+ md = qml_metadata(self.nodes[object_id])
+ file, component, scope = self.paths[object_id], md.get("component_key"), md.get("object_scope_key")
+ local = self.members.get((file, component, scope, name), [])
+ if local:
+ visible = [nid for nid in local if not qml_metadata(self.nodes[nid]).get("internal")]
+ return answer(visible, reason="member_not_visible")
+ type_name = md.get("type_name") or md.get("raw_type")
+ if not type_name:
+ return Resolution("unavailable", reason="receiver_type_unknown")
+ base = self.resolve_type(file, component or "", type_name)
+ if base.target_id is None:
+ return base
+ return combine([self._object_member(nid, name, seen + (object_id,))
+ for nid in self._root_objects(base.target_id)], reason="member_unavailable")
+
+ def resolve_member(self, file: str, component_key: str, object_scope_key: str,
+ name: str, *, lexical_names=()) -> Resolution:
+ """IDs precede properties; lexical JS bindings prevent any QML fallback."""
+ parts = name.split(".")
+ if parts[0] in lexical_names:
+ return Resolution("dynamic", reason="javascript_lexical_binding")
+ if parts[0] in {"this", "parent"}:
+ objects = self.objects.get((file, component_key, object_scope_key), [])
+ if parts[0] == "parent":
+ scopes = [qml_metadata(self.nodes[nid]).get("static_parent_scope_key") for nid in objects]
+ objects = [nid for scope in scopes if scope for nid in
+ self.objects.get((file, component_key, scope), [])]
+ owner = answer(objects, reason="runtime_parent_unestablished" if parts[0] == "parent" else
+ "receiver_scope_unavailable")
+ return (self.follow_member(owner.target_id, parts[1:]) if owner.target_id is not None and
+ len(parts) > 1 else owner)
+ candidates = []
+ for (path, component, _), ids in self.objects.items():
+ if path == file and component == component_key:
+ candidates.extend(nid for nid in ids if qml_metadata(self.nodes[nid]).get("object_id") == parts[0])
+ if candidates:
+ target = answer(candidates)
+ if len(parts) == 1 or target.target_id is None:
+ return target
+ return self._follow_member(target.target_id, parts[1:])
+ if len(parts) > 1:
+ # An own/root property precedes a type namespace with the same spelling.
+ head = self.resolve_member(file, component_key, object_scope_key, parts[0])
+ if head.target_id is not None:
+ return self.follow_member(head.target_id, parts[1:])
+ if head.status == "ambiguous":
+ return head
+ target = self.resolve_type(file, component_key, parts[0])
+ if target.target_id is not None:
+ md = self.md(target.target_id)
+ if not md.get("singleton"):
+ return Resolution("unsupported", reason="type_is_not_instance")
+ return combine([self._follow_member(nid, parts[1:]) for nid in self._root_objects(target.target_id)])
+ # Qualified imported singleton (Alias.State.value).
+ if len(parts) > 2:
+ target = self.resolve_type(file, component_key, ".".join(parts[:2]))
+ if target.target_id is not None and self.md(target.target_id).get("singleton"):
+ return combine([self._follow_member(nid, parts[2:]) for nid in self._root_objects(target.target_id)])
+ if target.status == "resolved":
+ return Resolution("unsupported", reason="type_is_not_instance")
+ return target
+ own_objects = self.objects.get((file, component_key, object_scope_key), [])
+ own = combine([self._object_member(nid, name) for nid in own_objects])
+ if own.status in {"resolved", "ambiguous"}:
+ return own
+ roots = [nid for nid, node in self.nodes.items() if self.paths[nid] == file and
+ qml_metadata(node).get("kind") in {"component", "inline_component"} and
+ qml_metadata(node).get("component_key") == component_key]
+ return combine([self._object_member(nid, name) for root in roots for nid in self._root_objects(root)],
+ reason="runtime_context_or_member_unavailable")
+
+ def _follow_member(self, object_id: str, parts: list[str]) -> Resolution:
+ target = self._object_member(object_id, parts[0])
+ if len(parts) == 1 or target.target_id is None:
+ return target
+ return self.follow_member(target.target_id, parts[1:])
+
+ def follow_member(self, target_id: str, parts) -> Resolution:
+ """Continue an established object/typed-property target, without alias execution."""
+ parts = list(parts)
+ if len(parts) > 32:
+ return Resolution("unsupported", reason="member_chain_limit")
+ if not parts:
+ return answer([target_id])
+ if self.native_index and self.native_index.is_provider(target_id):
+ return self._follow_member(target_id, parts)
+ md = qml_metadata(self.nodes[target_id])
+ if md.get("kind") == "object":
+ return self._follow_member(target_id, parts)
+ provider = self.resolve_type(self.paths[target_id], md.get("component_key") or "",
+ md.get("raw_type") or "")
+ if provider.target_id is None:
+ return provider
+ return combine([self._follow_member(nid, parts) for nid in self._root_objects(provider.target_id)])
diff --git a/graphify/qt_affected.py b/graphify/qt_affected.py
new file mode 100644
index 0000000000..c06f06a9b2
--- /dev/null
+++ b/graphify/qt_affected.py
@@ -0,0 +1,64 @@
+"""Source ownership propagation for Qt/QML dependency occurrence nodes."""
+from __future__ import annotations
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.graph_direction import logical_endpoints
+
+
+def _owned(node):
+ try:
+ return qml_metadata(node).get("contract_version") == 1 or qt_metadata(node).get("contract_version") == 1
+ except (ValueError, TypeError):
+ return False
+
+
+def _owns_source(parent, child, data, owner_id, child_id):
+ file = child.get("source_file")
+ if not file or data.get("source_file") != file:
+ return False
+ if parent.get("source_file") == file:
+ return True
+ # C++ canonical methods point at their header declaration. Their accepted
+ # implementation file can own a Qt site only with callable and exact owner,
+ # endpoint and original-span evidence from the extracted containment fact.
+ if parent.get("_callable") is not True or parent.get("definition_file") != file:
+ return False
+ child_qt, edge_qt = qt_metadata(child), qt_metadata(data)
+ return (child_qt.get("owner_id") == owner_id and edge_qt.get("target_id") == child_id
+ and bool(child_qt.get("span")) and child_qt.get("span") == edge_qt.get("span"))
+
+
+def owned_ancestors(graph, node_id, *, limit=256):
+ """Yield actual EXTRACTED contains owners without general forward traversal.
+
+ A dependency points from a source occurrence to its target. Its enclosing
+ binding/handler/component is affected through that source ownership, not by
+ an inferred signal-delivery call. Ordinary graph nodes retain old behavior.
+ """
+ pending, seen = [node_id], {node_id}
+ while pending and len(seen) <= limit:
+ current = pending.pop()
+ if current not in graph or not _owned(graph.nodes[current]):
+ continue
+ edges = graph.in_edges(current, data=True) if graph.is_directed() else graph.edges(current, data=True)
+ for source, target, data in edges:
+ endpoints = logical_endpoints(source, target, data, directed=graph.is_directed())
+ if endpoints is None:
+ continue
+ source, target = endpoints
+ if target != current or source not in graph or source in seen:
+ continue
+ if data.get("relation") != "contains" or data.get("confidence") != "EXTRACTED":
+ continue
+ parent, child = graph.nodes[source], graph.nodes[current]
+ try:
+ if not _owned(data) or not _owns_source(parent, child, data, source, current):
+ continue
+ except (ValueError, TypeError):
+ continue
+ if len(seen) >= limit:
+ return
+ seen.add(source)
+ yield str(source), data
+ pending.append(source)
diff --git a/graphify/qt_analysis_state.py b/graphify/qt_analysis_state.py
new file mode 100644
index 0000000000..231a3b3f54
--- /dev/null
+++ b/graphify/qt_analysis_state.py
@@ -0,0 +1,173 @@
+"""Bounded Qt analysis stamps; the caller owns successful publication ordering."""
+from __future__ import annotations
+
+import json
+import hashlib
+import os
+import re
+import posixpath
+from dataclasses import dataclass
+from importlib.metadata import PackageNotFoundError, version
+from pathlib import Path
+
+from graphify.extractors.qml_facts import CONTRACT_VERSION
+from graphify.paths import write_text_atomic
+from graphify.qt_incremental import QT_CPP_SUFFIXES, is_qt_metadata, is_qt_source, qt_analysis_fingerprint
+
+QT_STATE_FILE = ".qt_analysis.json"
+QT_STATE_SCHEMA = 1
+_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
+MAX_CONFIG_BYTES = 1_048_576
+
+
+@dataclass(frozen=True)
+class QtAnalysisState:
+ fingerprint: str
+ changed: bool
+ import_roots: tuple[str, ...]
+ has_qt: bool
+ current_has_qt: bool
+
+
+def _read_state(output_root: Path) -> dict | None:
+ path = Path(output_root) / QT_STATE_FILE
+ try:
+ if path.stat().st_size > 1024:
+ return None
+ with path.open("rb") as stream:
+ raw = stream.read(1025)
+ if len(raw) > 1024:
+ return None
+ value = json.loads(raw.decode("utf-8"))
+ except (OSError, UnicodeError, ValueError):
+ return None
+ if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != QT_STATE_SCHEMA:
+ return None
+ if not _valid_fingerprint(value.get("fingerprint")) or not isinstance(value.get("has_qt", False), bool):
+ return None
+ return value
+
+
+def _valid_fingerprint(value) -> bool:
+ return isinstance(value, str) and _DIGEST.fullmatch(value) is not None
+
+
+def read_qt_fingerprint(output_root: Path) -> str | None:
+ """Missing, incompatible and corrupted state force refresh, never cache reuse."""
+ value = _read_state(output_root)
+ return value["fingerprint"] if value else None
+
+
+def qt_configuration_changed(output_root: Path, fingerprint: str) -> bool:
+ if not _valid_fingerprint(fingerprint):
+ raise ValueError("QT_CONFIG: invalid analysis fingerprint")
+ return read_qt_fingerprint(output_root) != fingerprint
+
+
+def save_qt_fingerprint(output_root: Path, fingerprint: str, *, has_qt=False) -> None:
+ """Call only after graph/manifest publication succeeds; no automatic writeback."""
+ if not _valid_fingerprint(fingerprint):
+ raise ValueError("QT_CONFIG: invalid analysis fingerprint")
+ path = Path(output_root) / QT_STATE_FILE
+ path.parent.mkdir(parents=True, exist_ok=True)
+ write_text_atomic(path, json.dumps({"schema": QT_STATE_SCHEMA, "fingerprint": fingerprint, "has_qt": has_qt},
+ sort_keys=True) + "\n")
+
+
+def _import_roots(root: Path) -> tuple[str, ...]:
+ raw = os.environ.get("GRAPHIFY_QML_IMPORT_ROOTS", '["."]')
+ if len(raw.encode("utf-8")) > 65536:
+ raise ValueError("QT_CONFIG_LIMIT: import root configuration exceeds its byte bound")
+ try:
+ values = json.loads(raw)
+ except ValueError as exc:
+ raise ValueError("QT_CONFIG: import roots must be a JSON list") from exc
+ if not isinstance(values, list) or len(values) > 256:
+ raise ValueError("QT_CONFIG: import roots must be a bounded JSON list")
+ roots = []
+ for value in values:
+ if not isinstance(value, str) or not value or len(value) > 384:
+ raise ValueError("QT_CONFIG: malformed import root")
+ value = value.replace("\\", "/")
+ if value.startswith("/") or ":" in value or "\x00" in value or ".." in value.split("/"):
+ raise ValueError("QT_CONFIG: import roots must stay project-relative")
+ try:
+ (root / value).resolve().relative_to(root)
+ except ValueError as exc:
+ raise ValueError("QT_CONFIG: import root resolves outside the project") from exc
+ roots.append(posixpath.normpath(value))
+ return tuple(roots)
+
+
+def _package_versions() -> str:
+ versions = {}
+ for package in ("graphifyy", "tree-sitter", "tree-sitter-cpp", "tree-sitter-javascript",
+ "tree-sitter-language-pack"):
+ try:
+ versions[package] = version(package)
+ except PackageNotFoundError:
+ versions[package] = "absent"
+ return json.dumps(versions, sort_keys=True)
+
+
+def inspect_qt_analysis(root: Path, out: Path, accepted_paths, *, excludes=(), gitignore=True) -> QtAnalysisState:
+ """Inspect only accepted paths/ancestors, preserving root and configuration scope.
+
+ Configured import roots do not authorize a scan. Their ordered names and
+ parser contracts affect the stamp; corpus admission remains caller-owned.
+ """
+ root = Path(root).resolve()
+ roots = _import_roots(root)
+ paths, directories = [], {root}
+ for raw in accepted_paths:
+ path = Path(raw)
+ path = path if path.is_absolute() else root / path
+ try:
+ path = path.resolve()
+ relative = path.relative_to(root)
+ except ValueError as exc:
+ raise ValueError("QT_CONFIG: accepted input resolves outside the project") from exc
+ paths.append(relative.as_posix())
+ if relative.as_posix() == ".":
+ raise ValueError("QT_CONFIG: an accepted input must identify a source file")
+ parent = path.parent
+ while parent != root:
+ directories.add(parent)
+ parent = parent.parent
+ if len(paths) > 100_000 or len(directories) > 100_000:
+ raise ValueError("QT_CONFIG_LIMIT: accepted input inventory exceeds its bound")
+ configs = []
+ names = (".graphifyignore", ".gitignore") if gitignore else (".graphifyignore",)
+ for directory in sorted(directories):
+ for name in names:
+ path = directory / name
+ try:
+ path.resolve().relative_to(root)
+ with path.open("rb") as stream:
+ raw = stream.read(MAX_CONFIG_BYTES + 1)
+ except FileNotFoundError:
+ continue
+ except (OSError, ValueError) as exc:
+ raise ValueError("QT_CONFIG: ignore configuration is unreadable or outside root") from exc
+ if len(raw) > MAX_CONFIG_BYTES:
+ raise ValueError("QT_CONFIG_LIMIT: ignore configuration exceeds its byte bound")
+ configs.append((path.relative_to(root).as_posix(), hashlib.sha256(raw).hexdigest()))
+ config = {"accepted_paths": sorted(set(paths)), "ignore_files": configs,
+ "gitignore": bool(gitignore), "qt_fact_contract": 1}
+ profile = "qt6-static:" + hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
+ fingerprint = qt_analysis_fingerprint(parser_version=_package_versions(), fact_version=CONTRACT_VERSION,
+ import_roots=roots, ignore_patterns=excludes, profile=profile)
+ previous = _read_state(out)
+ current_has_qt = any(is_qt_source(path) or is_qt_metadata(path) or Path(path).suffix.lower() in QT_CPP_SUFFIXES
+ for path in paths)
+ return QtAnalysisState(fingerprint, previous is None or previous["fingerprint"] != fingerprint,
+ roots, current_has_qt or bool(previous and previous.get("has_qt")), current_has_qt)
+
+
+def commit_qt_analysis(out: Path, state: QtAnalysisState) -> None:
+ """Prepare the candidate stamp for its owner's accepted product cohort.
+
+ Manual/watch publication supplies its staging directory and commits the stamp
+ with graph/root/manifest products; writing this candidate alone accepts no run.
+ """
+ save_qt_fingerprint(out, state.fingerprint, has_qt=state.current_has_qt)
diff --git a/graphify/qt_context_bindings.py b/graphify/qt_context_bindings.py
new file mode 100644
index 0000000000..4d0ce5bfb2
--- /dev/null
+++ b/graphify/qt_context_bindings.py
@@ -0,0 +1,217 @@
+"""Source-established engine/component providers; no repository-wide injection."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import encode_qt, qt_edge, qt_id, qt_metadata, update_qt
+from graphify.extractors.qt_cpp_identity import source_reference_key
+from graphify.qml_resolution_types import qml_metadata
+from graphify.qt_event_index import QtEventIndex
+from graphify.qt_declared_provider import DeclaredProviderIndex
+from graphify.qt_context_paths import context_path
+from graphify.qt_context_subscriptions import project_context_subscriptions
+
+
+def _binding(site, item, component, provider, evidence):
+ metadata, source = qt_metadata(site), site["source_file"]
+ return {"id": qt_id(source, "context_binding", [site["id"], item.get("name"), item.get("ordinal", 0), provider]),
+ "label": "Qt provider: " + (item.get("name") or "context object"), "type": "concept", "file_type": "code",
+ "source_file": source, "source_location": site["source_location"], "_origin": "ast",
+ "metadata": {"qt": encode_qt({"kind": "context_binding", "span": metadata["span"], "owner_id": site["id"],
+ "exposed_name": item.get("name") or "", "context_object": metadata.get("operation") == "setContextObject",
+ "initial_property": metadata.get("kind") != "context_exposure", "provider_target_id": provider,
+ "declared_property_id": item.get("declared_property_id", ""),
+ "provider_class_name": item.get("provider_type", ""), "component_target_id": component,
+ "engine_reference": metadata.get("engine_reference") or metadata.get("receiver_reference") or "",
+ "engine_declaration_id": metadata.get("engine_declaration_id") or metadata.get("receiver_declaration_id") or "",
+ "provider_declaration_id": item.get("provider_declaration_id") or "",
+ "provider_type_evidence": item.get("provider_type_evidence", []),
+ "evidence": evidence,
+ "status": "resolved", "bridge_direction": "cpp_to_qml"})}}
+
+
+def resolve_context_bindings(results, index, all_nodes, all_edges):
+ native, loads = QtEventIndex(all_nodes), {}
+ declared_types = DeclaredProviderIndex(all_nodes)
+ for result in results:
+ for site in result.get("nodes", []) if result else []:
+ metadata = qt_metadata(site)
+ if metadata.get("kind") == "qml_load":
+ # Components have their own declaration and an explicit source
+ # engine; either role can supply its corresponding provider.
+ keys = {key for role in ("receiver", "engine")
+ if (key := source_reference_key(metadata, role)) is not None}
+ for key in keys:
+ loads.setdefault(key, []).append(site)
+ additions, fresh_ids = [], set()
+ for result in results:
+ if not result:
+ continue
+ for site in list(result.get("nodes", [])):
+ metadata = qt_metadata(site)
+ if metadata.get("kind") not in {"context_exposure", "initial_properties", "qml_root"}:
+ continue
+ if metadata.get("kind") == "qml_root" and metadata.get("operation") != "createWithInitialProperties":
+ continue
+ if metadata.get("kind") != "context_exposure" and not metadata.get("literal_properties"):
+ update_qt(site, status="dynamic", reason="computed_initial_properties")
+ continue
+ role = "engine" if metadata.get("kind") == "context_exposure" else "receiver"
+ key = source_reference_key(metadata, role)
+ candidates = loads.get(key, []) if key is not None else []
+ if key is None:
+ # Preserve authoritative lexical uncertainty instead of hiding
+ # a reassignment or duplicate behind a generic missing loader.
+ status = metadata.get(role + "_identity_status")
+ update_qt(site, status=status if status in {"dynamic", "ambiguous", "unsupported"} else "unavailable",
+ reason=metadata.get(role + "_identity_reason") or "provider_component_scope_unestablished")
+ continue
+ # A direct context alias without established engine identity remains
+ # unsupported. An old/later repeated load cannot prove one instance.
+ if len(candidates) != 1 or metadata.get("conditional"):
+ update_qt(site, status="dynamic" if metadata.get("conditional") else "ambiguous" if len(candidates) > 1 else "unavailable", reason="provider_component_scope_unestablished")
+ continue
+ load = candidates[0]
+ component = qt_metadata(load).get("target_id")
+ if not component:
+ continue
+ if metadata.get("kind") == "context_exposure":
+ items = [{"name": metadata.get("exposed_name"), "provider_type": metadata.get("provider_type"),
+ "provider_reference": metadata.get("provider_reference"),
+ "provider_declaration_id": metadata.get("provider_declaration_id"),
+ "provider_identity_status": metadata.get("provider_identity_status"),
+ "provider_identity_reason": metadata.get("provider_identity_reason"),
+ "provider_scope_end": metadata.get("provider_scope_end"),
+ "provider_root_type_id": metadata.get("provider_root_type_id"),
+ "provider_root_type_spelling": metadata.get("provider_root_type_spelling"),
+ "provider_root_address_of": metadata.get("provider_root_address_of"),
+ "provider_expression_steps": metadata.get("provider_expression_steps"),
+ "provider_type_status": metadata.get("provider_type_status")}]
+ if metadata.get("operation") == "setContextProperty" and not isinstance(metadata.get("exposed_name"), str):
+ update_qt(site, status="dynamic", reason="computed_context_name")
+ continue
+ else:
+ items = metadata.get("properties", [])
+ count = 0
+ for item in items:
+ # A provider declaration must still belong to this lexical
+ # lifetime when its engine loads the component. Names alone and
+ # old transport cannot authorize a cross-language relationship.
+ provider_key = source_reference_key({**metadata, "provider_declaration_id": item.get("provider_declaration_id")}, "provider")
+ scope_end = item.get("provider_scope_end")
+ if (provider_key is None or not isinstance(scope_end, int) or isinstance(scope_end, bool)
+ or qt_metadata(load)["span"]["start_byte"] >= scope_end):
+ continue
+ if metadata.get("kind") != "context_exposure":
+ root = index.root_object(component)
+ declared_property = index.member(root.target_id, item.get("name", ""), "property") if root.target_id else root
+ if not declared_property.target_id:
+ continue
+ item = {**item, "declared_property_id": declared_property.target_id}
+ if metadata.get("operation") == "setInitialProperties" and metadata["span"]["start_byte"] > qt_metadata(load)["span"]["start_byte"]:
+ continue
+ typed = declared_types.provider(item) if item.get("provider_root_type_id") else None
+ declared = native.class_name(item.get("provider_type") or "")
+ records = ([typed[0]] if typed else [] if item.get("provider_type_status") is not None
+ else native.classes.get(declared, []))
+ if len(records) != 1:
+ continue
+ provider = qt_metadata(records[0])
+ if not provider.get("is_qobject") or not provider.get("generic_target_id"):
+ continue
+ item = {**item, "provider_type": provider["class_name"], "provider_type_evidence": typed[1] if typed else [records[0]["id"]]}
+ node = _binding(site, item, component, provider["generic_target_id"], [site["id"], load["id"], records[0]["id"], component] + item["provider_type_evidence"])
+ existing = next((value for value in result.get("nodes", []) if value["id"] == node["id"]), None)
+ if existing is None:
+ result.setdefault("nodes", []).append(node)
+ additions.append(node)
+ fresh_ids.add(node["id"])
+ edges = [qt_edge(site, node["id"], "contains", "qt_context_binding"),
+ qt_edge(node, component, "uses", "qt_initial_property" if metadata.get("kind") != "context_exposure" else "qt_context_exposure", bridge_direction="cpp_to_qml")]
+ known = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])}
+ fresh_edges = [edge for edge in edges if (edge["source"], edge["target"], edge.get("context")) not in known]
+ result.setdefault("edges", []).extend(fresh_edges)
+ all_edges.extend(fresh_edges)
+ count += 1
+ if count:
+ update_qt(site, status="resolved", reason="", component_target_id=component, binding_count=count)
+ elif items:
+ # Initial-property lists may mix independent failure reasons.
+ # Report one provider identity reason only when every rejected
+ # item proves that exact failure; other rejections stay generic.
+ failures = {(item.get("provider_identity_status"), item.get("provider_identity_reason")) for item in items}
+ if len(failures) == 1:
+ status, reason = next(iter(failures))
+ if status in {"dynamic", "ambiguous", "unsupported", "unavailable"} and reason and all(
+ source_reference_key({**metadata, "provider_declaration_id": item.get("provider_declaration_id")}, "provider") is None
+ for item in items
+ ):
+ update_qt(site, status=status, reason=reason)
+ all_nodes.extend(additions)
+ additions = []
+ bindings = list({node["id"]: node for node in all_nodes if qt_metadata(node).get("kind") == "context_binding"}.values())
+ groups = {}
+ for binding in bindings:
+ metadata = qt_metadata(binding)
+ groups.setdefault((metadata.get("component_target_id"), metadata.get("exposed_name"), metadata.get("context_object")), []).append(binding)
+ blocked = set()
+ for group in groups.values():
+ if len(group) > 1:
+ blocked.update(binding["id"] for binding in group)
+ for binding in group:
+ if binding["id"] in fresh_ids:
+ update_qt(binding, status="ambiguous", reason="duplicate_context_provider")
+ return [binding for binding in bindings if binding["id"] not in blocked]
+
+
+def project_context_access(results, bridge, bindings, all_nodes, all_edges, qml_index):
+ """Join only unresolved source QML sites in the established loaded component."""
+ project_context_subscriptions(results, bridge, bindings, all_nodes, all_edges, qml_index)
+ by_component = {}
+ node_map = {node["id"]: node for node in all_nodes}
+ for binding in bindings:
+ metadata = qt_metadata(binding)
+ if metadata.get("status") != "resolved":
+ continue
+ component = node_map.get(metadata.get("component_target_id"), {})
+ key = (component.get("source_file"), qml_metadata(component).get("component_key"))
+ by_component.setdefault(key, []).append(binding)
+ for result in results:
+ if not result:
+ continue
+ for source in list(result.get("nodes", [])):
+ metadata = qml_metadata(source)
+ if metadata.get("kind") not in {"read", "call"} or metadata.get("status") != "unavailable" or metadata.get("lexical_shadowed") or metadata.get("script_file"):
+ continue
+ reference = metadata.get("reference", "").split(".")
+ imports = qml_index.imports.get(source["source_file"], [])
+ if any(qml_metadata(qml_index.nodes[nid]).get("qualifier") == reference[0] for nid in imports):
+ continue
+ matches = []
+ for binding in by_component.get((source["source_file"], metadata.get("component_key")), []):
+ provider = qt_metadata(binding)
+ local = qml_index.resolve_member(source["source_file"], metadata.get("component_key") or "", metadata.get("object_scope_key") or "", reference[0])
+ if local.status in {"resolved", "ambiguous", "unsupported", "dynamic"} and not (provider.get("initial_property") and local.target_id == provider.get("declared_property_id")):
+ continue
+ path = reference if provider.get("context_object") else reference[1:] if reference[0] == provider.get("exposed_name") else []
+ if path:
+ resolution, proof = context_path(bridge, binding, path)
+ if resolution.target_id:
+ matches.append((binding, resolution, proof))
+ if len(matches) != 1:
+ continue
+ binding, resolution, proof = matches[0]
+ span = metadata.get("span", {})
+ site = {"id": qt_id(source["source_file"], "context_access", [source["id"], binding["id"]]),
+ "label": "Qt context access: " + metadata.get("reference", ""), "type": "concept", "file_type": "code", "_origin": "ast",
+ "source_file": source["source_file"], "source_location": source["source_location"],
+ "metadata": {"qt": encode_qt({"kind": "context_access", "span": span, "owner_id": source["id"],
+ "binding_id": binding["id"], "target_id": resolution.target_id, "status": "resolved", "bridge_direction": "qml_to_cpp"})}}
+ update_qt(site, endpoint_proof=proof, evidence=list(resolution.evidence))
+ if any(value["id"] == site["id"] for value in result.get("nodes", [])):
+ continue
+ result.setdefault("nodes", []).append(site)
+ all_nodes.append(site)
+ edges = [qt_edge(source, site["id"], "contains", "qt_context_access", span=span),
+ qt_edge(site, resolution.target_id, "calls" if metadata.get("kind") == "call" and bridge.metadata(resolution.target_id).get("kind") == "function" else "uses", "qt_context_member", bridge_direction="qml_to_cpp",
+ endpoint_proof=proof, evidence=list(resolution.evidence))]
+ result.setdefault("edges", []).extend(edges)
+ all_edges.extend(edges)
diff --git a/graphify/qt_context_paths.py b/graphify/qt_context_paths.py
new file mode 100644
index 0000000000..22cd5b076a
--- /dev/null
+++ b/graphify/qt_context_paths.py
@@ -0,0 +1,96 @@
+"""Typed context-property chains borrow proven native APIs one hop at a time."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qml_resolution_types import Resolution
+from graphify.qt_declared_provider import DeclaredProviderIndex
+
+
+def context_path(bridge, binding, parts):
+ """A finite property path is static API evidence, never QObject tree identity."""
+ if not parts or len(parts) > 8:
+ return Resolution("unsupported", reason="context_type_chain_limit"), {}
+ types = DeclaredProviderIndex(bridge.nodes.values())
+ if not types.binding_valid(binding):
+ return Resolution("unavailable", reason="context_declared_provider_provenance_unavailable"), {}
+ current = qt_metadata(binding).get("provider_target_id")
+ visited, chain = {current}, []
+ resolution = bridge.context_member(binding, parts[0])
+ for name in parts[1:]:
+ previous = bridge.nodes.get(resolution.target_id)
+ if previous is None or qt_metadata(previous).get("kind") != "property":
+ return Resolution("unavailable", reason="context_child_property_unavailable"), {}
+ target, evidence = types.property_type(previous)
+ if not target or target in visited:
+ return Resolution("unsupported", reason="context_child_type_unavailable_or_cycle"), {}
+ chain.append({"property_id": previous["id"], "class_id": current,
+ "target_class_id": target, "evidence": evidence})
+ current = target
+ visited.add(current)
+ bridge.provider_records[binding["id"]] = {"class_id": current,
+ "raw_name": qt_metadata(binding).get("exposed_name"),
+ "evidence": tuple(qt_metadata(binding).get("evidence", [])) + tuple(
+ item for hop in chain for item in hop["evidence"]) + (binding["id"],)}
+ resolution = bridge.member(binding["id"], name)
+ proof = bridge.endpoint_proof(resolution.target_id, resolution.evidence) if resolution.target_id else {}
+ if proof:
+ proof["type_chain"] = chain
+ return resolution, proof
+
+
+def valid_context_chain(binding, proof, nodes):
+ """Every consumer repeats authoritative hop validation after serialization."""
+ chain = proof.get("type_chain", [])
+ if not isinstance(chain, list) or len(chain) > 8:
+ return False
+ current = binding.get("provider_target_id")
+ types = DeclaredProviderIndex(nodes)
+ binding_node = types.nodes.get(proof.get("provider_id"))
+ if binding_node is None or qt_metadata(binding_node) != binding or not types.binding_valid(binding_node):
+ return False
+ visited = {current}
+ for hop in chain:
+ if not isinstance(hop, dict) or hop.get("class_id") != current:
+ return False
+ property_node = types.nodes.get(hop.get("property_id"))
+ if property_node is None or qt_metadata(property_node).get("class_id") != current:
+ return False
+ target, evidence = types.property_type(property_node)
+ if not target or target in visited or target != hop.get("target_class_id"):
+ return False
+ if not isinstance(hop.get("evidence"), list) or not set(evidence) <= set(hop["evidence"]):
+ return False
+ current = target
+ visited.add(current)
+ return proof.get("class_id") == current
+
+
+def valid_context_endpoint(proof, nodes):
+ """A serialized role label cannot turn a private/ordinary method into a signal."""
+ member = nodes.get(proof.get("member_fact_id"))
+ target = nodes.get(proof.get("canonical_target_id"))
+ if member is None or target is None:
+ return False
+ md = qt_metadata(member)
+ types = DeclaredProviderIndex(nodes)
+ if not types.source_owned(member):
+ return False
+ if md.get("kind") == "property":
+ return proof.get("kind") == "property" and proof.get("member_fact_id") == proof.get("canonical_target_id")
+ roles = set(md.get("roles", []))
+ kind = "signal" if "signal" in roles else "function" if roles & {"invokable", "slot"} else ""
+ if proof.get("notify_property_id"):
+ prop = types.nodes.get(proof["notify_property_id"])
+ pmd = qt_metadata(prop) if prop is not None else {}
+ accessors = [node for node in types.nodes.values() if qt_metadata(node).get("kind") == "property_accessor"
+ and qt_metadata(node).get("property_id") == proof["notify_property_id"]
+ and qt_metadata(node).get("accessor_role") == "notify"]
+ if (pmd.get("kind") != "property" or pmd.get("class_id") != md.get("class_id")
+ or pmd.get("status") != "resolved" or pmd.get("constant")
+ or pmd.get("notify") != md.get("raw_name") or len(accessors) != 1
+ or not types.accessor_owned(accessors[0], prop, "notify")
+ or qt_metadata(accessors[0]).get("generic_target_id") != proof.get("canonical_target_id")):
+ return False
+ return bool(kind and proof.get("kind") == kind and md.get("kind") == "member"
+ and md.get("access") == "public" and md.get("generic_target_id") == proof.get("canonical_target_id")
+ and target.get("_callable") is True and target.get("_callable_class") is not True)
diff --git a/graphify/qt_context_subscriptions.py b/graphify/qt_context_subscriptions.py
new file mode 100644
index 0000000000..db38f287ed
--- /dev/null
+++ b/graphify/qt_context_subscriptions.py
@@ -0,0 +1,162 @@
+"""Source-owned context signal subscriptions and late implicit-parameter binding.
+
+Context providers are established after ordinary QML lookup. This boundary keeps
+subscriptions separate from calls and corrects only freshly owned handler uses.
+"""
+from __future__ import annotations
+
+from graphify.extractors.qml_facts import encode_metadata, qml_metadata
+from graphify.extractors.qt_cpp_facts import encode_qt, qt_edge, qt_id, qt_metadata
+from graphify.qt_context_paths import context_path
+
+
+def _binding_paths(source, reference, bindings, index):
+ md, matches = qml_metadata(source), []
+ parts = reference.split(".")
+ if not reference or md.get("lexical_shadowed") or md.get("script_file"):
+ return []
+ if any(qml_metadata(index.nodes[nid]).get("qualifier") == parts[0]
+ for nid in index.imports.get(source["source_file"], [])):
+ return []
+ local = index.resolve_member(source["source_file"], md.get("component_key", ""),
+ md.get("object_scope_key", ""), parts[0])
+ for binding in bindings:
+ provider = qt_metadata(binding)
+ component = index.nodes.get(provider.get("component_target_id"), {})
+ if (provider.get("status") != "resolved" or component.get("source_file") != source["source_file"]
+ or qml_metadata(component).get("component_key") != md.get("component_key")):
+ continue
+ if local.status in {"resolved", "ambiguous", "unsupported", "dynamic"} and not (
+ provider.get("initial_property") and local.target_id == provider.get("declared_property_id")):
+ continue
+ path = parts if provider.get("context_object") else parts[1:] if parts[0] == provider.get("exposed_name") else []
+ if path:
+ matches.append((binding, path))
+ return matches
+
+
+def project_context_subscriptions(results, bridge, bindings, nodes, edges, index):
+ """Only exact literal callbacks and handlers in a loaded component subscribe."""
+ additions, new_edges, implicit = [], [], {}
+ # Native Connections handlers establish legacy parameter binders before any
+ # nested signal.connect can resolve a provider or a callback of the same name.
+ for kind in ("handler", "call"):
+ _collect_subscriptions(results, bridge, bindings, index, kind, additions, new_edges, implicit)
+ if kind == "handler":
+ _suppress_implicit_parameters(results, nodes, edges, implicit)
+ nodes.extend(additions)
+ edges.extend(new_edges)
+
+
+def _collect_subscriptions(results, bridge, bindings, index, kind, additions, new_edges, implicit):
+ """Each pass borrows run-owned source facts and appends distinct subscription sites."""
+ for result in results:
+ if not result:
+ continue
+ for source in list(result.get("nodes", [])):
+ md = qml_metadata(source)
+ if md.get("kind") != kind:
+ continue
+ form, reference, handler = "", "", ""
+ if (md.get("kind") == "handler" and md.get("connections")
+ and not md.get("disabled_reason") and not md.get("attached_prefix")):
+ if md.get("connection_supplied") and md.get("connection_target"):
+ reference, form, handler = md["connection_target"] + "." + md.get("reference", ""), "Connections", source["id"]
+ elif md.get("kind") == "call" and md.get("reference", "").endswith(".connect"):
+ if md.get("callback_argument_count") != 1 or not md.get("callback_reference"):
+ continue
+ callback = md["callback_reference"]
+ if md.get("callback_lexical_shadowed"):
+ handler = md.get("callback_lexical_target_id")
+ else:
+ handler = index.resolve_member(source["source_file"], md.get("component_key", ""),
+ md.get("object_scope_key", ""), callback).target_id
+ if handler not in index.nodes or index.md(handler).get("kind") not in {"function", "js_function"}:
+ continue
+ reference, form = md["reference"][:-8], "signal.connect"
+ if not form:
+ continue
+ matches = _binding_paths(source, reference, bindings, index)
+ accepted = []
+ for binding, path in matches:
+ resolved, proof = context_path(bridge, binding, path)
+ if form == "Connections" and proof.get("kind") != "signal" and path[-1].endswith("Changed"):
+ # A native property notification retains its real NOTIFY
+ # declaration even when the handler uses property spelling.
+ prop, property_proof = context_path(bridge, binding, path[:-1] + [path[-1][:-7]])
+ if prop.target_id and property_proof.get("kind") == "property":
+ resolved = bridge.property_notify(prop.target_id, binding["id"])
+ proof = bridge.endpoint_proof(resolved.target_id, resolved.evidence) if resolved.target_id else {}
+ if proof:
+ proof.update(type_chain=property_proof.get("type_chain", []), notify_property_id=prop.target_id)
+ if resolved.target_id and proof.get("kind") == "signal":
+ accepted.append((binding, resolved, proof))
+ if len(accepted) != 1:
+ continue
+ binding, resolved, proof = accepted[0]
+ site = {"id": qt_id(source["source_file"], "context_subscription", [source["id"], binding["id"]]),
+ "label": "Qt context subscription: " + reference, "type": "concept", "file_type": "code", "_origin": "ast",
+ "source_file": source["source_file"], "source_location": source["source_location"],
+ "metadata": {"qt": encode_qt({"kind": "context_subscription", "span": md["span"],
+ "owner_id": source["id"], "binding_id": binding["id"], "status": "resolved",
+ "target_id": resolved.target_id, "handler_target_id": handler, "subscription_form": form,
+ "endpoint_proof": proof, "bridge_direction": "qml_to_cpp"})}}
+ if any(node["id"] == site["id"] for node in result.get("nodes", [])):
+ continue
+ additions.append(site)
+ result.setdefault("nodes", []).append(site)
+ # A separate handler endpoint avoids opposite relationships sharing
+ # one Graph/DiGraph pair when a Connections handler also owns the site.
+ endpoint = {**site, "id": qt_id(source["source_file"], "context_handler_endpoint", [site["id"], handler]),
+ "label": "Qt context handler endpoint", "metadata": {"qt": encode_qt({
+ "kind": "context_handler_endpoint", "span": md["span"], "owner_id": site["id"],
+ "target_id": handler, "status": "resolved"})}}
+ additions.append(endpoint)
+ result["nodes"].append(endpoint)
+ owned = [qt_edge(source, site["id"], "contains", "qt_context_subscription_site", span=md["span"]),
+ qt_edge(site, resolved.target_id, "references", "qt_context_subscription",
+ endpoint_proof=proof, bridge_direction="qml_to_cpp"),
+ qt_edge(site, endpoint["id"], "contains", "qt_context_handler_endpoint"),
+ qt_edge(endpoint, handler, "references", "qt_context_handler")]
+ new_edges.extend(owned)
+ result.setdefault("edges", []).extend(owned)
+ if form == "Connections" and md.get("implicit_parameters") is True:
+ implicit[source["id"]] = set(bridge.metadata(resolved.target_id).get("parameter_names", []))
+
+
+def _suppress_implicit_parameters(results, nodes, edges, parameters):
+ """Late native binders remove an earlier same-name QML fallback edge."""
+ by_id, suppressed = {node["id"]: node for node in nodes}, set()
+ for result in results:
+ if not result:
+ continue
+ for source in result.get("nodes", []):
+ md = qml_metadata(source)
+ if md.get("kind") not in {"read", "call"}:
+ continue
+ first, owner, seen = md.get("reference", "").split(".")[0], md.get("owner_id"), set()
+ callback = md.get("callback_reference", "").split(".")[0]
+ updated = {key: value for key, value in md.items() if key != "raw_values"}
+ changed = False
+ while owner in by_id and owner not in seen and len(seen) < 32:
+ seen.add(owner)
+ # Explicit JS binders already carry AST authority. A native
+ # implicit name must never displace a nearer local declaration.
+ if first in parameters.get(owner, set()) and not md.get("lexical_shadowed"):
+ updated.update(lexical_shadowed=True, lexical_target_id="", status="dynamic",
+ resolved_target_id="", reason="javascript_lexical_binding")
+ suppressed.add(source["id"])
+ changed = True
+ if callback in parameters.get(owner, set()) and not md.get("callback_lexical_shadowed"):
+ updated.update(callback_lexical_shadowed=True, callback_lexical_target_id="")
+ changed = True
+ owner = qml_metadata(by_id[owner]).get("owner_id")
+ if changed:
+ source["metadata"]["qml"] = encode_metadata(updated)
+ def kept(edge):
+ return not (edge.get("source") in suppressed and edge.get("context", "").startswith("qml_")
+ and edge.get("relation") in {"uses", "calls"})
+ edges[:] = [edge for edge in edges if kept(edge)]
+ for result in results:
+ if result:
+ result["edges"][:] = [edge for edge in result.get("edges", []) if kept(edge)]
diff --git a/graphify/qt_coverage.py b/graphify/qt_coverage.py
new file mode 100644
index 0000000000..148cca5934
--- /dev/null
+++ b/graphify/qt_coverage.py
@@ -0,0 +1,62 @@
+"""Bounded accepted-source status counts for Qt/QML reports, without source I/O."""
+from __future__ import annotations
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+
+STATUSES = ("resolved", "ambiguous", "dynamic", "unsupported", "unavailable", "error")
+
+
+def _source_fact(node, raw):
+ """Version and original span establish a fact; labels never infer coverage."""
+ if not isinstance(raw, dict) or type(raw.get("contract_version")) is not int or raw["contract_version"] != 1:
+ return False
+ span = raw.get("span")
+ return (isinstance(node.get("source_file"), str) and bool(node["source_file"])
+ and isinstance(raw.get("kind"), str) and bool(raw["kind"])
+ and isinstance(span, dict) and type(span.get("start_byte")) is int
+ and type(span.get("end_byte")) is int and 0 <= span["start_byte"] <= span["end_byte"])
+
+
+def source_coverage(graph):
+ """Count each source-owned node once per contract, keeping unknown states explicit."""
+ counts = {}
+ for _, node in graph.nodes(data=True):
+ metadata = node.get("metadata")
+ if not isinstance(metadata, dict):
+ continue
+ for name, decode in (("qml", qml_metadata), ("qt", qt_metadata)):
+ if not _source_fact(node, metadata.get(name)):
+ continue
+ row = counts.setdefault(name, {**dict.fromkeys(STATUSES, 0), "other": 0, "facts": 0, "invalid_metadata": 0})
+ row["facts"] += 1
+ try:
+ status = decode(node).get("status")
+ except (ValueError, TypeError, UnicodeError):
+ status = "error"
+ row["invalid_metadata"] += 1
+ row[status if status in STATUSES else "other"] += 1
+ return counts
+
+
+def coverage_lines(graph):
+ """Do not equate accepted-fact resolution with complete Qt runtime coverage."""
+ counts = source_coverage(graph)
+ if not counts:
+ return []
+ lines = ["", "## Qt/QML source coverage", "",
+ "Counts describe accepted source facts, not a percentage of supported language syntax.", "",
+ "| Contract | Facts | Resolved | Ambiguous | Dynamic | Unsupported | Unavailable | Error | Other / no status |",
+ "|---|---:|---:|---:|---:|---:|---:|---:|---:|"]
+ for name in ("qml", "qt"):
+ if name not in counts:
+ continue
+ row = counts[name]
+ cells = [name.upper(), row["facts"], *(row[status] for status in STATUSES), row["other"]]
+ lines.append("| " + " | ".join(str(cell) for cell in cells) + " |")
+ invalid = sum(row["invalid_metadata"] for row in counts.values())
+ if invalid:
+ lines.append(f"- {invalid} source fact(s) have invalid semantic metadata; counted as errors.")
+ lines += ["", "Static resolution does not prove runtime object creation, binding values, signal delivery, ordering, or thread safety.",
+ "Parse and graph-write failures belong to their owning diagnostics; their absence from this graph is not proof of successful extraction."]
+ return lines
diff --git a/graphify/qt_declared_provider.py b/graphify/qt_declared_provider.py
new file mode 100644
index 0000000000..837e6d11b4
--- /dev/null
+++ b/graphify/qt_declared_provider.py
@@ -0,0 +1,179 @@
+"""Validate accepted declaration-type hops for a context provider's public API."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_api_shape import api_shape
+from graphify.extractors.cpp_class_proof import class_fact
+from graphify.extractors.cpp_constructors import _decoded
+from graphify.extractors.cpp_member_identity import member_fact
+
+
+def _api_kind(member):
+ shape = api_shape(qt_metadata(member).get("api_type_spelling"))
+ return shape[1] if shape else ""
+
+
+class DeclaredProviderIndex:
+ """One run owns borrowed type evidence; no source reads or global name guesses."""
+ def __init__(self, nodes):
+ # NetworkX stores identity in mapping keys; raw extraction stores it in
+ # each dictionary. Borrow data without mutating either representation.
+ self.nodes = ({identity: {**node, "id": identity} for identity, node in nodes.items()}
+ if hasattr(nodes, "items") else {node["id"]: node for node in nodes})
+ self.classes, self.members, self.accessors = {}, {}, {}
+ for node in self.nodes.values():
+ md = qt_metadata(node)
+ if md.get("kind") == "class" and md.get("is_definition") is True:
+ self.classes.setdefault(md.get("class_id"), []).append(node)
+ elif md.get("kind") in {"member", "api_field", "property"}:
+ self.members.setdefault((md.get("class_id"), md.get("raw_name")), []).append(node)
+ elif md.get("kind") == "property_accessor":
+ self.accessors.setdefault((md.get("property_id"), md.get("accessor_role")), []).append(node)
+
+ def class_record(self, identity, *, qobject=False):
+ records = self.classes.get(identity, [])
+ if (len(records) != 1 or identity not in self.nodes
+ or self.nodes[identity].get("_callable_class") is not True):
+ return None
+ md = qt_metadata(records[0])
+ canonical = self.nodes[identity]
+ fact = class_fact(canonical)
+ if (md.get("status") != "resolved" or md.get("api_declaration_conditional") is not False
+ or (qobject and not md.get("is_qobject")) or not fact or fact.get("ambiguous")
+ or fact.get("is_definition") is not True or md.get("owner_id") != identity
+ or md.get("span") != fact.get("span")
+ or records[0].get("source_file") != canonical.get("source_file")
+ or md.get("class_name") != _decoded(fact.get("qualified_name_b64"))):
+ return None
+ return records[0]
+
+ def source_owned(self, declaration):
+ """Borrow exact original class/member proof; no corpus rereads or name repair."""
+ md = qt_metadata(declaration)
+ parent = self.class_record(md.get("class_id"))
+ if parent is None or md.get("owner_id") != md.get("class_id") or md.get("status") != "resolved":
+ return False
+ owner, span = qt_metadata(parent), md.get("span", {})
+ bounds = owner.get("span", {})
+ if (not isinstance(span, dict) or any(type(span.get(key)) is not int or span[key] < 0 for key in bounds)
+ or declaration.get("source_file") != parent.get("source_file")
+ or md.get("class_name") != owner.get("class_name")
+ or not bounds["start_byte"] < span["start_byte"] < span["end_byte"] < bounds["end_byte"]
+ or not (bounds["start_row"], bounds["start_column"]) < (span["start_row"], span["start_column"])
+ or not (span["end_row"], span["end_column"]) < (bounds["end_row"], bounds["end_column"])
+ or declaration.get("source_location") != f"L{span['start_row'] + 1}-L{span['end_row'] + 1}"):
+ return False
+ if md.get("kind") == "member":
+ target = self.nodes.get(md.get("generic_target_id"), {})
+ fact = member_fact(target)
+ return bool(fact and not fact.get("ambiguous") and fact.get("role") == "declaration"
+ and fact.get("span") == span and target.get("source_file") == declaration.get("source_file")
+ and _decoded(fact.get("owner_b64")) == md.get("class_name")
+ and _decoded(fact.get("member_b64")) == md.get("raw_name"))
+ return md.get("kind") in {"api_field", "property"}
+
+ def accessor_owned(self, accessor, property_node, role):
+ """Accessor links repeat the annotation's exact owner, name, file and span."""
+ md, prop = qt_metadata(accessor), qt_metadata(property_node)
+ return bool(self.source_owned(property_node) and md.get("kind") == "property_accessor"
+ and md.get("status") == "resolved" and md.get("accessor_role") == role
+ and md.get("owner_id") == property_node["id"] and md.get("property_id") == property_node["id"]
+ and isinstance(prop.get(role), str) and md.get("raw_name") == prop[role]
+ and md.get("span") == prop.get("span")
+ and accessor.get("source_file") == property_node.get("source_file")
+ and accessor.get("source_location") == property_node.get("source_location"))
+
+ def binding_valid(self, binding):
+ """Typed bindings retain the original lexical declaration and expression proof."""
+ md = qt_metadata(binding)
+ owner = self.nodes.get(md.get("owner_id"))
+ if owner is None:
+ return False
+ source = qt_metadata(owner)
+ if source.get("kind") != "context_exposure" or source.get("provider_type_status") is None:
+ return True # Initial-property and earlier direct-provider contracts.
+ provider = self.provider(source)
+ return bool(provider and qt_metadata(provider[0]).get("class_id") == md.get("provider_target_id")
+ and source.get("provider_declaration_id") == md.get("provider_declaration_id")
+ and source.get("provider_identity_status") == "resolved"
+ and isinstance(md.get("provider_type_evidence"), list)
+ and set(provider[1]) <= set(md["provider_type_evidence"]))
+
+ def api_type(self, member):
+ md = qt_metadata(member)
+ raw, target = md.get("api_type_spelling"), md.get("api_type_target_id")
+ position = md.get("api_type_position")
+ owner = self.class_record(md.get("class_id"))
+ if (md.get("status") != "resolved" or md.get("api_type_status") != "resolved"
+ or api_shape(raw) is None or not self.source_owned(member)
+ or not isinstance(position, int) or isinstance(position, bool)
+ or owner is None or not qt_metadata(owner)["span"]["start_byte"] < position <= md.get("span", {}).get("start_byte", -1)
+ or self.class_record(target, qobject=True) is None):
+ return ""
+ return target
+
+ def provider(self, metadata):
+ root = metadata.get("provider_root_type_id")
+ steps = metadata.get("provider_expression_steps")
+ shape = api_shape(metadata.get("provider_root_type_spelling"), allow_value=True)
+ address_of = metadata.get("provider_root_address_of")
+ if (metadata.get("provider_type_status") != "resolved" or self.class_record(root) is None
+ or shape is None or not isinstance(address_of, bool)):
+ return None
+ if not isinstance(steps, list) or len(steps) > 8:
+ return None
+ current, evidence, visited, current_shape = root, [root], set(), shape[1]
+ if address_of and (steps or current_shape == "pointer"):
+ return None
+ for step in steps:
+ if not isinstance(step, dict) or set(step) != {"name", "role", "operator"}:
+ return None
+ if step["operator"] != ("->" if current_shape == "pointer" else "."):
+ return None
+ if step["role"] not in {"call", "field"} or not isinstance(step["name"], str):
+ return None
+ candidates = self.members.get((current, step["name"]), [])
+ candidates = [node for node in candidates if qt_metadata(node).get("kind") ==
+ ("member" if step["role"] == "call" else "api_field")]
+ if len(candidates) != 1 or (current, step["name"]) in visited:
+ return None
+ member, md = candidates[0], qt_metadata(candidates[0])
+ if md.get("access") != "public" or (step["role"] == "call" and
+ (md.get("parameter_types") != [] or md.get("generic_target_id") not in self.nodes)):
+ return None
+ current = self.api_type(member)
+ if not current:
+ return None
+ current_shape = _api_kind(member)
+ visited.add((md["class_id"], step["name"]))
+ evidence.extend((member["id"], current))
+ if not steps and not address_of and current_shape != "pointer":
+ return None
+ record = self.class_record(current, qobject=True)
+ return (record, list(dict.fromkeys(evidence + [record["id"]]))) if record is not None else None
+
+ def property_type(self, property_node):
+ """Q_PROPERTY and its accepted accessor/member must agree on the API type."""
+ md = qt_metadata(property_node)
+ target = self.api_type(property_node)
+ if not target:
+ return "", []
+ if isinstance(md.get("read"), str):
+ links = self.accessors.get((property_node["id"], "read"), [])
+ if len(links) != 1 or not self.accessor_owned(links[0], property_node, "read"):
+ return "", []
+ accessor = qt_metadata(links[0]).get("generic_target_id")
+ methods = [node for node in self.members.get((md["class_id"], md["read"]), [])
+ if qt_metadata(node).get("generic_target_id") == accessor]
+ if (len(methods) != 1 or qt_metadata(methods[0]).get("parameter_types") != []
+ or self.api_type(methods[0]) != target
+ or _api_kind(methods[0]) != _api_kind(property_node)):
+ return "", []
+ return target, [property_node["id"], links[0]["id"], methods[0]["id"], target]
+ if isinstance(md.get("member"), str):
+ fields = [node for node in self.members.get((md["class_id"], md["member"]), [])
+ if qt_metadata(node).get("kind") == "api_field"]
+ if (len(fields) == 1 and self.api_type(fields[0]) == target
+ and _api_kind(fields[0]) == _api_kind(property_node)):
+ return target, [property_node["id"], fields[0]["id"], target]
+ return "", []
diff --git a/graphify/qt_event_index.py b/graphify/qt_event_index.py
new file mode 100644
index 0000000000..ce5dcce96e
--- /dev/null
+++ b/graphify/qt_event_index.py
@@ -0,0 +1,186 @@
+"""Exact accepted Qt member lookup and deliberately bounded compatibility proof."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_mapping import normalize_type
+from graphify.qml_resolution_types import Resolution, answer
+import re
+
+
+def value_type(value):
+ value = normalize_type(value)
+ if "*" in value or "&&" in value:
+ return value
+ const = bool(re.search(r"\bconst\b", value))
+ value = re.sub(r"\bconst\b", "", value)
+ return normalize_type(value.replace("&", "") if const else value)
+
+
+class QtEventIndex:
+ """Class-qualified declarations, never a repository-wide member-name table."""
+
+ def __init__(self, nodes):
+ self.nodes = {node["id"]: node for node in nodes}
+ self.classes, self.members, self.callables = {}, {}, {}
+ for node in nodes:
+ metadata = qt_metadata(node)
+ if metadata.get("kind") == "class":
+ # A forward declaration is source evidence, not a competing
+ # definition. Missing legacy completeness is also unproven.
+ if metadata.get("is_definition") is True:
+ self.classes.setdefault(metadata.get("class_name"), []).append(node)
+ elif metadata.get("kind") == "member":
+ self.members.setdefault((metadata.get("class_name"), metadata.get("raw_name")), []).append(node)
+ elif metadata.get("kind") == "callable":
+ self.callables.setdefault((node.get("source_file"), metadata.get("callable_name")), []).append(node)
+
+ def class_name(self, name):
+ if (name in self.classes and len(self.classes[name]) == 1
+ and qt_metadata(self.classes[name][0]).get("status") == "resolved"):
+ return name
+ return ""
+
+ def _bases(self, name):
+ """Only the producer's lexical base identity can authorize traversal."""
+ metadata = qt_metadata(self.classes[name][0])
+ bases, raw = metadata.get("canonical_base_names"), metadata.get("bases")
+ if (metadata.get("canonical_base_status") != "resolved" or not isinstance(bases, list)
+ or not isinstance(raw, list) or len(bases) != len(raw) or len(bases) > 50
+ or not all(isinstance(base, str) and base for base in bases)):
+ return None
+ return bases
+
+ def _base_access(self, name, bases):
+ """Legacy or malformed facts never acquire a C++ inheritance default."""
+ metadata = qt_metadata(self.classes[name][0])
+ access = metadata.get("canonical_base_access")
+ if (metadata.get("canonical_base_access_status") != "resolved" or not isinstance(access, list)
+ or len(access) != len(bases) or not all(isinstance(value, str)
+ and value in {"public", "protected", "private"} for value in access)):
+ return None
+ return access
+
+ def _lookup_members(self, declared, name, *, public_only=False):
+ """Stop each branch at its first declaration before filtering its role.
+
+ A hidden or incompatible declaration shadows ancestors just as a signal
+ does. Traversal owns one bounded run-local memo; failed or missing base
+ proof cannot select a convenient declaration from another branch.
+ """
+ memo, visited = {}, set()
+
+ def visit(owner, path):
+ owner = self.class_name(owner)
+ if not owner:
+ return [], "inheritance_identity_unavailable"
+ if owner in path:
+ return [], "inheritance_cycle_or_limit"
+ if owner in memo:
+ return memo[owner]
+ if len(visited) >= 32:
+ return [], "inheritance_cycle_or_limit"
+ visited.add(owner)
+ candidates = list(self.members.get((owner, name), []))
+ if candidates:
+ memo[owner] = candidates, ""
+ return memo[owner]
+ bases = self._bases(owner)
+ if bases is None:
+ return [], "inheritance_identity_unavailable"
+ access = self._base_access(owner, bases) if public_only else ["public"] * len(bases)
+ if access is None:
+ return [], "inheritance_access_unavailable"
+ for base, visibility in zip(bases, access):
+ inherited, reason = visit(base, path | {owner})
+ if reason:
+ return [], reason
+ if public_only and inherited and visibility != "public":
+ return [], "inheritance_access_unavailable"
+ candidates.extend(inherited)
+ memo[owner] = candidates, ""
+ return memo[owner]
+
+ return visit(declared, frozenset())
+
+ def inherits(self, actual, declared, seen=None, *, public_only=False):
+ actual, declared = self.class_name(actual), self.class_name(declared)
+ if not actual or not declared:
+ return False
+ if actual == declared:
+ return True
+ seen = set() if seen is None else seen
+ if actual in seen or len(seen) >= 32:
+ return False
+ seen.add(actual)
+ bases = self._bases(actual)
+ if bases is None:
+ return False
+ access = self._base_access(actual, bases) if public_only else ["public"] * len(bases)
+ return bool(access is not None and any(self.inherits(base, declared, seen, public_only=public_only)
+ for base, visibility in zip(bases, access) if visibility == "public"))
+
+ def member(self, endpoint, actual_type, *, role=None, owner_class=""):
+ if endpoint.get("form") not in {"legacy", "member_pointer"}:
+ return Resolution("dynamic", reason="dynamic_endpoint")
+ actual = self.class_name(actual_type)
+ declared = self.class_name(endpoint.get("class_name") or actual_type)
+ if not actual or not declared:
+ return Resolution("unavailable", reason="unestablished_object_type")
+ public_only = endpoint.get("form") == "member_pointer"
+ if not self.inherits(actual, declared):
+ return Resolution("unavailable", reason="object_member_type_mismatch")
+ if public_only and not self.inherits(actual, declared, public_only=True):
+ return Resolution("unavailable", reason="inheritance_access_unavailable")
+ candidates, reason = self._lookup_members(declared, endpoint.get("member_name"), public_only=public_only)
+ if reason:
+ return Resolution("unavailable", reason=reason)
+ grouped = {}
+ for node in candidates:
+ metadata = qt_metadata(node)
+ key = (metadata.get("generic_target_id") or node["id"], metadata.get("signature"))
+ grouped.setdefault(key, []).append(node)
+ candidates = [min(group, key=lambda node: (qt_metadata(node).get("access") == "public", not bool(qt_metadata(node).get("roles")), node["id"])) for group in grouped.values()]
+ # Distinct declaring classes are ambiguous even if a selector or role
+ # happens to match only one branch. Repeated diamond paths to the exact
+ # same canonical declaration have already deduplicated above.
+ if len({qt_metadata(node).get("class_name") for node in candidates}) > 1:
+ return answer([node["id"] for node in candidates], evidence=("source_type",))
+ candidates = [node for node in candidates if qt_metadata(node).get("status") == "resolved"]
+ if role:
+ candidates = [node for node in candidates if role in qt_metadata(node).get("roles", [])]
+ if endpoint.get("selector"):
+ normalizer = value_type if endpoint.get("form") == "legacy" else normalize_type
+ wanted = [normalizer(value) for value in endpoint.get("parameter_types", [])]
+ candidates = [node for node in candidates if [normalizer(value) for value in qt_metadata(node).get("parameter_types", [])] == wanted]
+ if endpoint.get("form") == "member_pointer":
+ candidates = [node for node in candidates if qt_metadata(node).get("access") == "public"
+ or qt_metadata(node).get("class_name") == owner_class]
+ return answer([node["id"] for node in candidates], reason="member_not_proven", evidence=("source_type", "declared_signature"))
+
+ def emission(self, metadata):
+ expression = {"form": "legacy", "member_name": metadata.get("member_name"), "selector": False}
+ result = self.member(expression, metadata.get("receiver_type") or "", role="signal")
+ if result.status == "resolved":
+ parameters = qt_metadata(self.nodes[result.target_id]).get("parameter_types", [])
+ if len(parameters) != metadata.get("argument_count"):
+ return Resolution("unavailable", reason="signal_argument_count_mismatch")
+ return result
+
+ def compatible(self, signal_id, receiver_id=None, parameters=None):
+ offered = qt_metadata(self.nodes[signal_id]).get("parameter_types", [])
+ required = parameters if parameters is not None else qt_metadata(self.nodes[receiver_id]).get("parameter_types", [])
+ if len(required) > len(offered):
+ return False
+ # This profile proves exact normalized types only. Compiler conversions,
+ # typedefs, and templated callable deduction stay explicitly unsupported.
+ return all(value_type(left) == value_type(right) for left, right in zip(offered, required))
+
+ def callable(self, site):
+ metadata = qt_metadata(site)
+ if metadata.get("callable_form") == "functor":
+ declared = self.class_name(metadata.get("callable_type", ""))
+ candidates = self.members.get((declared, "operator()"), [])
+ candidates = [node for node in candidates if qt_metadata(node).get("access") == "public"]
+ else:
+ candidates = self.callables.get((site.get("source_file"), metadata.get("callable_reference")), [])
+ return answer([node["id"] for node in candidates], reason="callable_signature_unestablished", evidence=("source_callable",))
diff --git a/graphify/qt_event_resolution.py b/graphify/qt_event_resolution.py
new file mode 100644
index 0000000000..7c999c6308
--- /dev/null
+++ b/graphify/qt_event_resolution.py
@@ -0,0 +1,114 @@
+"""Project Qt emissions and connection endpoints without propagation calls."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import encode_qt, qt_edge, qt_id, qt_metadata, scope_owner, update_qt
+from graphify.qt_event_index import QtEventIndex
+from graphify.qml_resolution_types import Resolution
+
+
+def _record(site, prefix, result):
+ update_qt(site, **{prefix + "_status": result.status, prefix + "_target_id": result.target_id or "",
+ prefix + "_reason": result.reason, prefix + "_candidates": list(result.candidates)})
+
+
+def _role(site, target, role, result, additions, edges):
+ """Separate role facts retain signal-to-signal and repeated occurrence edges."""
+ metadata = qt_metadata(site)
+ node = {"id": qt_id(site["source_file"], "event_endpoint", [site["id"], role]),
+ "label": "Qt connection " + role, "type": "concept", "file_type": "code", "_origin": "ast",
+ "source_file": site["source_file"], "source_location": site["source_location"],
+ "metadata": {"qt": encode_qt({"kind": "event_endpoint", "role": role, "owner_id": site["id"],
+ "span": metadata["span"], "target_id": target,
+ "status": result.status, "bridge_direction": "cpp_to_cpp"})}}
+ additions.append(node)
+ # Endpoint role ownership follows the observed connection syntax. The
+ # independently resolved declaration edge below remains inferred.
+ ownership = qt_edge(site, node["id"], "contains", "qt_connection_endpoint", role=role)
+ ownership["confidence"] = "EXTRACTED"
+ edges.append(ownership)
+ edges.append(qt_edge(node, target, "references", "qt_" + site.get("metadata", {}).get("qt", {}).get("kind", "connect") + "_" + role,
+ role=role, bridge_direction="cpp_to_cpp"))
+
+
+def resolve_qt_events(per_file, all_nodes, all_edges, *, root, project_index=None):
+ """Mutate only fresh source-owned sites; borrowed accepted corpus stays read-only."""
+ del root, project_index
+ index = QtEventIndex(all_nodes)
+ connection_handles = {}
+ results = per_file.values() if isinstance(per_file, dict) else per_file
+ for result in results:
+ if not result:
+ continue
+ additions, projected = [], []
+ for site in list(result.get("nodes", [])):
+ metadata = qt_metadata(site)
+ kind = metadata.get("kind")
+ if kind == "emission":
+ resolution = index.emission(metadata)
+ update_qt(site, status=resolution.status, target_id=resolution.target_id or "", reason=resolution.reason,
+ candidates=list(resolution.candidates), bridge_direction="cpp_to_cpp")
+ if resolution.target_id:
+ projected.append(qt_edge(site, resolution.target_id, "uses", "qt_signal_emit", bridge_direction="cpp_to_cpp"))
+ continue
+ if kind not in {"connect", "disconnect"}:
+ continue
+ if kind == "disconnect" and metadata.get("handle_reference"):
+ handle = connection_handles.get((scope_owner(metadata), metadata["handle_reference"]))
+ if handle and handle[1] == metadata.get("handle_assignment_byte") and not metadata.get("conditional"):
+ update_qt(site, status="resolved", target_id=handle[0], reason="", bridge_direction="cpp_to_cpp")
+ projected.append(qt_edge(site, handle[0], "uses", "qt_disconnect", bridge_direction="cpp_to_cpp"))
+ else:
+ update_qt(site, status="dynamic", reason="connection_handle_unestablished")
+ continue
+ signal = index.member(metadata.get("signal", {}), metadata.get("sender_type", ""), role="signal",
+ owner_class=metadata.get("owner_class", ""))
+ _record(site, "signal", signal)
+ receiver_id, reason = "", ""
+ if signal.target_id:
+ _role(site, signal.target_id, "signal", signal, additions, projected)
+ form = metadata.get("callable_form")
+ if form in {"member_pointer", "legacy"}:
+ endpoint = metadata.get("receiver", {})
+ receiver = index.member(endpoint, metadata.get("receiver_type", ""),
+ role=endpoint.get("role") or None, owner_class=metadata.get("owner_class", ""))
+ _record(site, "receiver", receiver)
+ if receiver.target_id and signal.target_id and index.compatible(signal.target_id, receiver.target_id):
+ receiver_id = receiver.target_id
+ _role(site, receiver_id, "receiver", receiver, additions, projected)
+ else:
+ reason = receiver.reason or "incompatible_or_unestablished_signature"
+ elif form == "lambda" and metadata.get("lambda_id"):
+ receiver_id = metadata["lambda_id"]
+ parameters = qt_metadata(index.nodes.get(receiver_id, {})).get("parameter_types", [])
+ if not signal.target_id or not index.compatible(signal.target_id, parameters=parameters):
+ receiver_id, reason = "", "incompatible_or_unestablished_lambda_signature"
+ else:
+ _role(site, receiver_id, "receiver", Resolution("resolved", receiver_id), additions, projected)
+ elif form in {"functor", "function"}:
+ callable_result = index.callable(site)
+ _record(site, "receiver", callable_result)
+ if callable_result.target_id and signal.target_id and index.compatible(signal.target_id, callable_result.target_id):
+ receiver_id = callable_result.target_id
+ _role(site, receiver_id, "receiver", callable_result, additions, projected)
+ else:
+ reason = callable_result.reason or "incompatible_callable_signature"
+ else:
+ reason = "dynamic_callable"
+ status = "resolved" if signal.target_id and receiver_id else "ambiguous" if signal.status == "ambiguous" else "dynamic" if form == "dynamic" else "unavailable"
+ if kind == "disconnect" and metadata.get("signal_wildcard") and metadata.get("callable_wildcard"):
+ status = "resolved" if index.class_name(metadata.get("sender_type", "")) else "unavailable"
+ reason = "source_wildcard_disconnect" if status == "resolved" else "sender_type_unestablished"
+ update_qt(site, status=status, reason=reason or signal.reason, receiver_target_id=receiver_id,
+ bridge_direction="cpp_to_cpp", unique_connection_effect="not_guaranteed_for_functors" if form in {"lambda", "functor", "function"} else "unverified")
+ if kind == "connect" and status == "resolved" and metadata.get("assigned_handle") and not metadata.get("conditional"):
+ connection_handles[(scope_owner(metadata), metadata["assigned_handle"])] = (site["id"], metadata.get("assignment_byte"))
+ existing = {node["id"] for node in result.get("nodes", [])}
+ fresh = [node for node in additions if node["id"] not in existing]
+ result.setdefault("nodes", []).extend(fresh)
+ if all_nodes is not result["nodes"]:
+ all_nodes.extend(node for node in fresh if node["id"] not in index.nodes)
+ known = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])}
+ new_edges = [edge for edge in projected if (edge["source"], edge["target"], edge.get("context")) not in known]
+ result.setdefault("edges", []).extend(new_edges)
+ if all_edges is not result["edges"]:
+ all_edges.extend(new_edges)
diff --git a/graphify/qt_export.py b/graphify/qt_export.py
new file mode 100644
index 0000000000..3078ef6f02
--- /dev/null
+++ b/graphify/qt_export.py
@@ -0,0 +1,118 @@
+"""Qt/QML graph-database payloads retain literal facts and logical direction."""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+
+from graphify.graph_direction import logical_endpoints as accepted_endpoints
+
+
+def has_qt_qml(graph) -> bool:
+ return any(_typed(data) for _, data in graph.nodes(data=True)) or any(
+ _typed(data) for _, _, data in graph.edges(data=True))
+
+
+def _typed(data) -> bool:
+ metadata = data.get("metadata")
+ if not isinstance(metadata, dict):
+ return False
+ return any(isinstance(value := metadata.get(name), dict)
+ and type(value.get("contract_version")) is int and value["contract_version"] == 1
+ for name in ("qml", "qt"))
+
+
+def export_properties(data, *, preserve_metadata=False) -> dict:
+ """Database properties are scalar; nested semantic payloads use exact JSON.
+
+ The compatibility path preserves the existing scalar-only property view.
+ Qt/QML graphs retain metadata/attributes as separately named JSON strings,
+ including canonical endpoint evidence and lossless literal transport.
+ """
+ if preserve_metadata:
+ _reserved(data, {"metadata_json", "attributes_json"})
+ result = {key: value for key, value in data.items() if isinstance(key, str)
+ and not key.startswith("_") and isinstance(value, (str, int, float, bool))}
+ if preserve_metadata:
+ for key in ("metadata", "attributes"):
+ if isinstance(data.get(key), (dict, list)):
+ result[key + "_json"] = _json(data[key])
+ return result
+
+
+def logical_endpoints(source, target, data, *, directed=False):
+ """Exports share consumer pair validation while retaining their stable rejection code."""
+ endpoints = accepted_endpoints(source, target, data, directed=directed)
+ if endpoints is None:
+ raise ValueError("QT_EXPORT_DIRECTION: invalid logical edge pair; re-extract or repair the graph")
+ return endpoints
+
+
+def edge_properties(source, target, data, *, preserve_metadata=False) -> dict:
+ result = export_properties(data, preserve_metadata=preserve_metadata)
+ if preserve_metadata:
+ _reserved(data, {"graphify_source", "graphify_target", "graphify_fact_key"})
+ result["graphify_source"], result["graphify_target"] = source, target
+ result["graphify_fact_key"] = hashlib.sha256(_json([source, target, result]).encode()).hexdigest()
+ return result
+
+
+def _reserved(data, keys):
+ if any(key in data for key in keys):
+ raise ValueError("QT_EXPORT_PROPERTY_COLLISION: reserved transport property already exists")
+
+
+def preflight_qt_payload(graph) -> None:
+ """Validate the complete Qt transport before connecting or writing externally."""
+ for _, data in graph.nodes(data=True):
+ export_properties(data, preserve_metadata=True)
+ for source, target, data in graph.edges(data=True):
+ # External exports honor complete logical pairs on bidirectional storage
+ # wrappers. JSON separately checks native directed identity when persisted.
+ src, tgt = logical_endpoints(source, target, data)
+ edge_properties(src, tgt, data, preserve_metadata=True)
+
+
+def _json(value) -> str:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
+
+
+def _identifier(value, fallback):
+ name = re.sub(r"[^A-Za-z0-9_]", "_", str(value)) or fallback
+ return name if not name[0].isdigit() else fallback + "_" + name
+
+
+def _map(values):
+ # Escaped identifier keys and non-interpolated JSON string literals prevent
+ # property data from becoming executable Cypher. Live pushes use parameters.
+ entries = []
+ for key, value in sorted(values.items()):
+ quoted_key = "`" + key.replace("`", "``") + "`"
+ entries.append(quoted_key + ":" + _json(value))
+ return "{" + ",".join(entries) + "}"
+
+
+def qt_cypher_lines(graph) -> list[str] | None:
+ """Return a Qt-aware complete upsert script, or the untouched legacy path.
+
+ Source facts remain inspectable JSON properties. Stable relationship keys
+ preserve distinct mechanisms even for parallel accepted endpoint pairs.
+ This writer does not execute a database or remove earlier database records.
+ """
+ if not has_qt_qml(graph):
+ return None
+ lines = ["// Qt/QML Cypher transport: metadata_json retains literal source facts.",
+ "// Upsert only; stale database records need an explicit replacement policy.", ""]
+ for node_id, data in graph.nodes(data=True):
+ props = export_properties(data, preserve_metadata=True)
+ props["id"] = node_id
+ label = _identifier(str(data.get("file_type") or "Entity").capitalize(), "Entity")
+ lines.append(f"MERGE (n:{label} {{id:{_json(node_id)}}}) SET n += {_map(props)};")
+ for source, target, data in graph.edges(data=True):
+ src, tgt = logical_endpoints(source, target, data)
+ props = edge_properties(src, tgt, data, preserve_metadata=True)
+ relation = _identifier(str(data.get("relation") or "RELATES_TO").upper(), "RELATES_TO")
+ key = _json(props["graphify_fact_key"])
+ lines.append(f"MATCH (a {{id:{_json(src)}}}), (b {{id:{_json(tgt)}}}) "
+ f"MERGE (a)-[r:{relation} {{graphify_fact_key:{key}}}]->(b) SET r += {_map(props)};")
+ return lines
diff --git a/graphify/qt_generated_conflicts.py b/graphify/qt_generated_conflicts.py
new file mode 100644
index 0000000000..4eb2fa54eb
--- /dev/null
+++ b/graphify/qt_generated_conflicts.py
@@ -0,0 +1,41 @@
+"""Report generated tooling/source disagreements while preserving both origins."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qml_resolution_types import qml_metadata
+
+
+def generated_conflicts(index) -> list[dict]:
+ diagnostics = []
+ native_members = {}
+ for node in index.nodes.values():
+ md = qt_metadata(node)
+ if md.get("kind") in {"property", "member"} and md.get("class_name"):
+ native_members.setdefault((md["class_name"], md.get("raw_name")), []).append(node)
+
+ def add(generated, source_id, reason):
+ if len(diagnostics) < 50:
+ md = qml_metadata(generated)
+ diagnostics.append({"code": "QML_TYPES_CONFLICT", "severity": "warning",
+ "owner": "qt_project_index", "source_file": generated["source_file"],
+ "span": md.get("span"), "reason": reason,
+ "generated_id": generated["id"], "source_id": source_id,
+ "message": "Both generated and source declarations are retained",
+ "recovery": "Regenerate tooling metadata or reconcile the source declarations."})
+
+ for node in index.nodes.values():
+ md = qml_metadata(node)
+ if md.get("kind") == "qmltypes_export":
+ source = index.resolve_component(md["uri"], md["raw_name"], md.get("major"), md.get("minor"))
+ if source.target_id or source.status == "ambiguous":
+ add(node, source.target_id, "generated_and_source_type_origins")
+ elif md.get("kind") == "qmltypes_member":
+ for source in native_members.get((md.get("cpp_name"), md.get("raw_name")), []):
+ native = qt_metadata(source)
+ pairs = [(md.get("raw_type"), native.get("raw_type") if native.get("kind") == "property"
+ else native.get("return_type"))]
+ if native.get("kind") == "property":
+ pairs.append((md.get("notify"), native.get("notify")))
+ if any(a and b and a != b for a, b in pairs):
+ add(node, source["id"], "generated_member_disagrees_with_source")
+ return diagnostics
diff --git a/graphify/qt_html.py b/graphify/qt_html.py
new file mode 100644
index 0000000000..7417383486
--- /dev/null
+++ b/graphify/qt_html.py
@@ -0,0 +1,45 @@
+"""Qt/QML HTML fields borrow complete contracts and project readable semantics."""
+from __future__ import annotations
+
+import json
+
+from graphify.qt_qml_search import SEARCH_NAMESPACE, search_attributes
+from graphify.qt_relationship_views import relationship_label
+from graphify.security import sanitize_label
+
+
+def semantic_fields(data):
+ metadata = data.get("metadata")
+ if not isinstance(metadata, dict) or not any(isinstance(metadata.get(name), dict)
+ and type(metadata[name].get("contract_version")) is int
+ and metadata[name]["contract_version"] == 1 for name in ("qml", "qt")):
+ return {}
+ # The encoder reads these dictionaries only. Existing attributes, including
+ # an occupied public search namespace, retain their authoritative values.
+ attributes = search_attributes(data)
+ fields = {"metadata": metadata, "attributes": attributes,
+ "source_location": str(data.get("source_location") or "")}
+ original = data.get("attributes")
+ if SEARCH_NAMESPACE in attributes and not (isinstance(original, dict) and SEARCH_NAMESPACE in original):
+ fields["qt_qml"] = attributes[SEARCH_NAMESPACE]
+ return fields
+
+
+def semantic_title(fields):
+ value = fields.get("qt_qml")
+ return "\n" + sanitize_label(json.dumps(value, ensure_ascii=False)) if value else ""
+
+
+def edge_fields(data):
+ fields = semantic_fields(data)
+ if fields:
+ fields.update(context=data.get("context", ""), source_file=data.get("source_file", ""))
+ return fields
+
+
+def edge_title(data):
+ label = relationship_label(data)
+ if not label:
+ return ""
+ source = str(data.get("source_file") or "") + ":" + str(data.get("source_location") or "")
+ return sanitize_label(" — " + label + " at " + source)
diff --git a/graphify/qt_incremental.py b/graphify/qt_incremental.py
new file mode 100644
index 0000000000..0efda81d42
--- /dev/null
+++ b/graphify/qt_incremental.py
@@ -0,0 +1,115 @@
+"""Pure Qt input/cache/update policy, over caller-owned accepted corpus snapshots."""
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Iterable
+from dataclasses import dataclass
+from pathlib import Path
+
+QT_METADATA_SUFFIXES = frozenset({".qmltypes", ".cmake", ".pro", ".pri", ".qrc"})
+QT_CPP_SUFFIXES = frozenset({".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".hxx"})
+QT_SCRIPT_SUFFIXES = frozenset({".js", ".mjs", ".cjs"})
+QT_NAMED_METADATA = frozenset({"qmldir", "CMakeLists.txt"})
+# Followed Qt metadata now retains its walked source owner. Refresh unchanged
+# prior products through the existing publication owner rather than reusing
+# canonical-only metadata facts; the syntax cache has its separate epoch.
+QT_POLICY_VERSION = 22
+
+
+def is_qt_metadata(path: str | Path) -> bool:
+ p = Path(path)
+ return p.name in QT_NAMED_METADATA or p.suffix.lower() in QT_METADATA_SUFFIXES
+
+
+def is_qt_source(path: str | Path) -> bool:
+ return Path(path).suffix.lower() == ".qml"
+
+
+def qt_syntax_cache_bypass(path: str | Path, *, native=False) -> bool:
+ """Qt source/metadata stays uncached; native syntax bypass is explicit."""
+ return is_qt_source(path) or is_qt_metadata(path) or (native and Path(path).suffix.lower() in QT_CPP_SUFFIXES)
+
+
+def requires_native_refresh(paths: Iterable[str | Path]) -> bool:
+ """Inspect only accepted bounded inputs; comments may conservatively refresh.
+
+ A Qt corpus must reparse canonical native declarations when its installed
+ parser changes. Plain C++ without Qt/recovery candidates retains its cache.
+ """
+ import re
+ paths = tuple(Path(path) for path in paths)
+ if any(is_qt_source(path) or is_qt_metadata(path) for path in paths):
+ return True
+ # Same-version fork upgrades can change valid default/Q_UNUSED syntax.
+ # Comments adjacent to a possible empty default conservatively refresh;
+ # only the parser's lexical/AST adapter determines actual source semantics.
+ markers = re.compile(rb"Q_OBJECT|Q_GADGET|Q_PROPERTY|Q_INVOKABLE|QML_|qmlRegister|QQml|QQuickView|QObject|Q_SIGNALS|Q_SLOTS|Q_SIGNAL|Q_SLOT|Q_EMIT|SIGNAL\s*\(|SLOT\s*\(|Q_UNUSED\b|=\s*(?:\{\s*(?:\}|/\*|//)|/\*|//)")
+ for path in paths:
+ if path.suffix.lower() not in QT_CPP_SUFFIXES:
+ continue
+ try:
+ with path.open("rb") as stream:
+ raw = stream.read(5_000_001)
+ except OSError:
+ return True
+ if len(raw) > 5_000_000 or markers.search(raw):
+ return True
+ return False
+
+
+@dataclass(frozen=True)
+class QtRefreshPlan:
+ required: bool
+ reasons: tuple[str, ...]
+ accepted_inputs: tuple[str, ...]
+
+
+def plan_qt_refresh(corpus_paths: Iterable[str | Path], changed_paths: Iterable[str | Path], *,
+ prior_paths: Iterable[str | Path] = (), configuration_changed=False,
+ qt_facts_present=False) -> QtRefreshPlan:
+ """A conservative plan rebuilds only inputs already admitted by the caller.
+
+ Prior paths establish context after a last-provider deletion; they never
+ authorize re-reading that provider. No glob, directory walk or stat runs.
+ C++ changes can introduce Qt semantics, so C++ corpora conservatively refresh
+ rather than use a stale native-event index when a changed source gains Qt.
+ """
+ corpus = tuple(str(path) for path in corpus_paths)
+ changed = tuple(str(path) for path in changed_paths)
+ prior = tuple(str(path) for path in prior_paths)
+ has_qt = qt_facts_present or any(is_qt_source(path) or is_qt_metadata(path)
+ or Path(path).suffix.lower() in QT_CPP_SUFFIXES
+ for path in (*corpus, *prior))
+ reasons = set()
+ if configuration_changed and has_qt:
+ reasons.add("qt_analysis_configuration_changed")
+ for path in changed:
+ suffix = Path(path).suffix.lower()
+ if is_qt_source(path) or is_qt_metadata(path):
+ reasons.add("qt_source_or_metadata_changed")
+ elif suffix in QT_CPP_SUFFIXES:
+ reasons.add("qt_native_provider_changed")
+ elif has_qt and suffix in QT_SCRIPT_SUFFIXES:
+ reasons.add("qt_script_provider_changed")
+ elif has_qt and Path(path).name in {".graphifyignore", ".gitignore"}:
+ reasons.add("qt_corpus_policy_changed")
+ return QtRefreshPlan(bool(reasons), tuple(sorted(reasons)), tuple(sorted(set(corpus))))
+
+
+def qt_analysis_fingerprint(*, parser_version: str, fact_version: int,
+ import_roots: Iterable[str] = (".",),
+ ignore_patterns: Iterable[str] = (), profile="qt6-static") -> str:
+ """A caller-persisted stamp covers semantic inputs beyond per-file contents."""
+ roots, ignores = tuple(import_roots), tuple(ignore_patterns)
+ if len(roots) > 256 or len(ignores) > 10_000:
+ raise ValueError("QT_CONFIG_LIMIT: analysis input count exceeds supported bounds")
+ values = (*roots, *ignores, parser_version, profile)
+ if any(not isinstance(value, str) or len(value) > 4096 for value in values):
+ raise ValueError("QT_CONFIG_LIMIT: malformed analysis configuration")
+ if not isinstance(fact_version, int) or fact_version < 1:
+ raise ValueError("QT_CONFIG: invalid fact contract version")
+ payload = {"policy_version": QT_POLICY_VERSION, "parser_version": parser_version,
+ "fact_version": fact_version, "import_roots": roots,
+ "ignore_patterns": ignores, "profile": profile}
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
diff --git a/graphify/qt_orphan_cleanup.py b/graphify/qt_orphan_cleanup.py
new file mode 100644
index 0000000000..533a3d9ff1
--- /dev/null
+++ b/graphify/qt_orphan_cleanup.py
@@ -0,0 +1,89 @@
+"""Retire stale generic AST placeholders after a complete accepted Qt refresh.
+
+The caller owns discovery, extraction, reconciliation and publication. This pure
+step never expands the corpus or mutates a borrowed graph dictionary. It removes
+only unreferenced placeholders omitted by the successful fresh extraction.
+"""
+from __future__ import annotations
+
+from graphify.graph_direction import logical_endpoints
+
+
+def previous_import_targets(prior: dict | None, *, import_relations: frozenset[str]) -> set[str]:
+ """Derive exact external candidates from accepted prior source-owned AST imports.
+
+ The caller supplies the builder's canonical import family. Duplicate IDs,
+ missing owners and corrupted logical pairs cannot authorize cleanup; labels
+ and sourceless semantic edges are never source ownership evidence.
+ """
+ if not isinstance(prior, dict):
+ return set()
+ nodes, edges = prior.get("nodes", []), prior.get("links", prior.get("edges", []))
+ if not isinstance(nodes, list) or not isinstance(edges, list):
+ return set()
+ owners, duplicates = {}, set()
+ for node in nodes:
+ if not isinstance(node, dict) or not isinstance(node.get("id"), str):
+ continue
+ identity = node["id"]
+ if identity in owners:
+ duplicates.add(identity)
+ else:
+ owners[identity] = node
+ targets = set()
+ for edge in edges:
+ if (not isinstance(edge, dict) or edge.get("_origin") != "ast"
+ or not isinstance(edge.get("relation"), str) or edge["relation"] not in import_relations
+ or edge.get("context") != "import"
+ or edge.get("confidence") != "EXTRACTED"):
+ continue
+ pair = logical_endpoints(edge.get("source"), edge.get("target"), edge,
+ directed=bool(prior.get("directed", False)))
+ if pair is None or not all(isinstance(value, str) for value in pair):
+ continue
+ source, target = pair
+ if source in duplicates or target in duplicates or target not in owners:
+ continue
+ owner, source_file = owners.get(source, {}), edge.get("source_file")
+ if (isinstance(source_file, str) and source_file and owner.get("source_file") == source_file
+ and owner.get("_origin") == "ast" and owner.get("file_type") == "code"):
+ targets.add(target)
+ return targets
+
+
+def prune_stale_ast_orphans(result: dict, *, fresh_ids: set[str], complete_refresh: bool,
+ prior_import_ids: set[str] | None = None) -> dict:
+ """Return a filtered view; partial refreshes and authoritative facts survive.
+
+ Empty source provenance alone is insufficient. A generic AST placeholder or
+ source-owned prior import endpoint must also be absent from fresh output and
+ every surviving ordinary/hyperedge reference. Borrowed inputs stay unchanged.
+ """
+ if not complete_refresh:
+ return result
+ external_ids = prior_import_ids or set()
+ referenced = set()
+ for edge in result.get("edges", []):
+ referenced.update((edge.get("source"), edge.get("target")))
+ for edge in result.get("hyperedges", []):
+ members = edge.get("nodes", edge.get("members", edge.get("node_ids", [])))
+ if isinstance(members, list):
+ referenced.update(value for value in members if isinstance(value, str))
+
+ def stale(node):
+ generic_ast = (node.get("_origin") == "ast" and node.get("file_type") == "code"
+ and not node.get("type"))
+ # The builder-created external shape acquires semantic origin on reload.
+ # Exact prior AST import identity supplies its omitted ownership proof;
+ # an arbitrary same-label semantic/user node cannot authorize removal.
+ generic_external = (node.get("id") in external_ids
+ and node.get("file_type") == "concept" and node.get("type") == "external"
+ and node.get("external") is True and node.get("label") == node.get("id")
+ and node.get("_origin") in (None, "semantic") and "metadata" not in node)
+ return (node.get("id") not in fresh_ids and node.get("id") not in referenced
+ and (generic_ast or generic_external) and not node.get("source_file")
+ and not node.get("source_location") and not node.get("metadata")
+ and not any(key.startswith("_callable") for key in node))
+
+ retained = [node for node in result.get("nodes", []) if not stale(node)]
+ return {**result, "nodes": retained}
diff --git a/graphify/qt_project_index.py b/graphify/qt_project_index.py
new file mode 100644
index 0000000000..59a14d134e
--- /dev/null
+++ b/graphify/qt_project_index.py
@@ -0,0 +1,145 @@
+"""Per-run literal Qt build/module/resource index over the accepted corpus only."""
+from __future__ import annotations
+
+import posixpath
+from pathlib import Path
+
+from graphify.extractors.qml_project_read import literal_path
+from graphify.extractors.qml_resources import resource_url
+from graphify.qml_module_index import QmlModuleIndex
+from graphify.qml_resolution_types import Resolution, answer, qml_metadata, source_path
+from graphify.qt_generated_conflicts import generated_conflicts
+from graphify.qt_resource_index import QtResourceIndex, normalize_url
+
+
+class QtProjectIndex:
+ """Borrow all source dictionaries read-only; derived lookup records stay local."""
+ def __init__(self, nodes, edges, *, root: Path, import_roots=None):
+ self.root = Path(root).resolve()
+ self.nodes, self.paths, self.modules, self.sources = {}, {}, {}, {}
+ for node in nodes:
+ path = source_path(node, self.root)
+ if path is not None and node.get("id"):
+ self.nodes[node["id"]], self.paths[node["id"]] = node, path
+ md = qml_metadata(node)
+ if md.get("kind") == "qt_module":
+ self.modules.setdefault(md.get("module_key"), []).append(node["id"])
+ elif md.get("kind") == "qt_source":
+ self.sources.setdefault(md.get("module_key"), []).append(node["id"])
+ self.module_index = QmlModuleIndex(nodes, edges, root=self.root, import_roots=import_roots)
+ self.resource_index = QtResourceIndex(nodes, root=self.root)
+ self.memberships, self.qml_files = {}, {}
+ for key, records in self.sources.items():
+ for nid in records:
+ md = qml_metadata(self.nodes[nid])
+ path = literal_path(self.paths[nid], md.get("value", ""))
+ if path is None or path not in self.module_index.by_file:
+ continue
+ self.memberships.setdefault(path, []).extend(self.modules.get(key, []))
+ if md.get("source_kind") == "qml":
+ self.qml_files.setdefault(key, []).append((path, nid))
+ elif md.get("source_kind") == "qrc":
+ for aid in self.module_index.by_file[path]:
+ alias = qml_metadata(self.nodes[aid])
+ if alias.get("kind") == "resource_alias":
+ target = alias.get("target_path")
+ if target in self.module_index.components:
+ self.qml_files.setdefault(key, []).append((target, aid))
+ self.diagnostics = generated_conflicts(self)
+
+ def module_metadata(self, module_id: str) -> dict:
+ return dict(qml_metadata(self.nodes[module_id]))
+
+ def module_context(self, source_file: str) -> Resolution:
+ normalized = source_path({"source_file": source_file}, self.root)
+ ids = self.memberships.get(normalized, [])
+ result = answer(ids, reason="source_module_context_unavailable", evidence=ids)
+ return Resolution(result.status, result.target_id, result.reason,
+ tuple(sorted(set(ids))[:50]), result.candidates)
+
+ def _eligible(self, md, major, minor):
+ if major is None:
+ return True
+ if major != md.get("major"):
+ return False
+ return minor is None or (isinstance(md.get("minor"), int) and minor <= md["minor"])
+
+ def module_import(self, uri: str, major=None, minor=None) -> Resolution:
+ """Build-declared namespaces require exact URI/version/provider evidence."""
+ providers = []
+ for ids in self.modules.values():
+ for mid in ids:
+ md = qml_metadata(self.nodes[mid])
+ if md.get("uri") == uri and self._eligible(md, major, minor):
+ providers.append(mid)
+ # A common CMake target spelling does not prove two declarations share
+ # build provenance. Only the exact source-owned identity may coalesce.
+ evidence = tuple(sorted(set(providers))[:50])
+ if len(set(providers)) > 1:
+ return Resolution("ambiguous", reason="duplicate_module_provider",
+ candidates=evidence,
+ evidence=evidence)
+ if providers:
+ return Resolution("resolved", evidence[0], evidence=evidence)
+ return Resolution("unavailable", reason="module_or_version_unavailable")
+
+ def module_type(self, uri, major, minor, name, *, importer="", **kwargs) -> Resolution:
+ """Match the established QML index seam without adding runtime visibility."""
+ return self.resolve_component(uri, name, major, minor)
+
+ def resolve_component(self, uri: str, type_name: str, major=None, minor=None) -> Resolution:
+ """Resolve declared module QML sources, plus established qmldir visibility."""
+ namespace = self.module_import(uri, major, minor)
+ if namespace.status == "ambiguous":
+ return namespace
+ candidates, evidence, providers = [], [], []
+ for key, ids in self.modules.items():
+ for mid in ids:
+ md = qml_metadata(self.nodes[mid])
+ if md.get("uri") != uri:
+ continue
+ providers.append(mid)
+ if not self._eligible(md, major, minor):
+ continue
+ for path, sid in self.qml_files.get(key, []):
+ name = posixpath.basename(path).removesuffix(".ui.qml").removesuffix(".qml")
+ if name == type_name and name[:1].isupper():
+ candidates.extend(self.module_index.components.get(path, []))
+ evidence.extend((mid, sid))
+ declared = answer(candidates, reason="module_type_unavailable", evidence=evidence)
+ existing = self.module_index.module_type(uri, major, minor, type_name, importer="")
+ if existing.status in {"ambiguous", "unsupported"}:
+ return existing
+ if existing.target_id:
+ return answer(candidates + [existing.target_id], evidence=evidence + list(existing.evidence))
+ if declared.target_id or declared.status == "ambiguous":
+ return declared
+ if providers:
+ return Resolution("unavailable", reason="module_type_or_version_unavailable", evidence=tuple(providers[:50]))
+ return existing
+
+ def resolve_url(self, literal_url: str, base_url: str | None = None) -> Resolution:
+ # CMake-generated aliases exist only when the prefix is explicit; policy
+ # defaults and source-property aliases are never guessed.
+ result = self.resource_index.resolve_url(literal_url, base_url)
+ if result.reason != "resource_alias_unavailable":
+ return result
+ logical = normalize_url(literal_url, base_url)
+ if isinstance(logical, Resolution):
+ return logical
+ matches, evidence = [], []
+ for key, ids in self.modules.items():
+ for mid in ids:
+ md = qml_metadata(self.nodes[mid])
+ prefix = md.get("resource_prefix")
+ if not isinstance(prefix, str):
+ continue
+ prefix = prefix.rstrip("/")
+ if not md.get("no_resource_target_path"):
+ prefix += "/" + md["uri"].replace(".", "/")
+ for path, sid in self.qml_files.get(key, []):
+ smd = qml_metadata(self.nodes[sid])
+ if resource_url(prefix, smd.get("value", "")) == logical:
+ matches.extend(self.module_index.components.get(path, []))
+ evidence.extend((mid, sid))
+ return answer(matches, reason="resource_alias_unavailable", evidence=evidence)
diff --git a/graphify/qt_project_membership.py b/graphify/qt_project_membership.py
new file mode 100644
index 0000000000..557f86bd19
--- /dev/null
+++ b/graphify/qt_project_membership.py
@@ -0,0 +1,199 @@
+"""Publish literal Qt metadata membership over accepted, immutable source facts."""
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path, PurePosixPath
+
+from graphify.extractors.qml_project_read import literal_path
+from graphify.extractors.qml_source_identity import relative_qml_source
+from graphify.qml_resolution_types import Resolution, answer, fact_edge, fact_node, qml_metadata, source_path
+from graphify.qt_source_file_role import generic_file_role
+
+_CONTEXTS = {"qt_membership_site", "qt_project_source", "qt_resource_membership"}
+
+
+def _ast(node, fresh_ids):
+ origin = node.get("_origin")
+ return origin == "ast" or (origin is None and node.get("id") in fresh_ids)
+
+
+def _location(node, span):
+ start, end = span["start_row"] + 1, span["end_row"] + 1
+ if node.get("source_location") not in {f"L{start}", f"L{start}-L{end}"}:
+ raise ValueError("QML_METADATA: membership declaration location conflicts with span")
+
+
+def _span(metadata):
+ """Invalid transported locations are integrity failures, not guessed spans."""
+ value = metadata.get("span")
+ fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column")
+ if (not isinstance(value, dict)
+ or any(type(value.get(key)) is not int or value[key] < 0 for key in fields)
+ or value["end_byte"] <= value["start_byte"] or value["end_row"] < value["start_row"]
+ or (value["start_row"] == value["end_row"]
+ and value["end_column"] - value["start_column"] != value["end_byte"] - value["start_byte"])):
+ raise ValueError("QML_METADATA: invalid membership source span")
+ return dict(value)
+
+
+def _unsupported(metadata):
+ if metadata.get("generated"):
+ return "generated_membership_unestablished"
+ if metadata.get("conditional"):
+ return "conditional_membership_unestablished"
+ return ""
+
+
+def _file_role(node, path, fresh_ids):
+ metadata = qml_metadata(node)
+ if not _ast(node, fresh_ids) or node.get("_callable") or node.get("_callable_class"):
+ return False
+ if metadata.get("kind") == "file":
+ return node.get("type") == "file" and node.get("label") == PurePosixPath(path).name
+ return generic_file_role(node, path, fresh_ids)
+
+
+def _target(index, path, fresh_ids):
+ """A QML component precedes its file node; other sources need an exact file role."""
+ components = index.module_index.components.get(path, [])
+ if components:
+ records = [index.nodes[nid] for nid in components]
+ reason = next((_unsupported(qml_metadata(node)) for node in records
+ if _unsupported(qml_metadata(node))), "")
+ if reason:
+ return Resolution("unsupported", reason=reason)
+ accepted = [node["id"] for node in records if _ast(node, fresh_ids)
+ and node.get("type") == "class"]
+ return answer(accepted, reason="membership_component_provenance_unavailable")
+ files = [nid for nid in index.module_index.by_file.get(path, [])
+ if _file_role(index.nodes[nid], path, fresh_ids)]
+ return answer(files, reason="membership_target_outside_accepted_corpus")
+
+
+def _module(index, declaration, metadata, fresh_ids):
+ ids = sorted(set(index.modules.get(metadata.get("module_key"), [])))
+ if len(ids) != 1:
+ return answer(ids, reason="membership_module_unavailable")
+ mid = ids[0]
+ module = index.nodes[mid]
+ md = qml_metadata(module)
+ if not _ast(module, fresh_ids) or module.get("type") != "namespace":
+ return Resolution("unavailable", reason="membership_module_provenance_unavailable")
+ _location(module, _span(md))
+ if index.paths[mid] != index.paths[declaration["id"]]:
+ return Resolution("unavailable", reason="membership_module_source_conflict")
+ if md.get("kind") != "qt_module" or md.get("module_key") != metadata.get("module_key"):
+ return Resolution("unavailable", reason="membership_module_unavailable")
+ if reason := _unsupported(md):
+ return Resolution("unsupported", reason=reason)
+ return Resolution("resolved", mid, evidence=(mid,))
+
+
+def _source(index, declaration, metadata, fresh_ids):
+ module = _module(index, declaration, metadata, fresh_ids)
+ if module.status != "resolved":
+ return module
+ path = literal_path(index.paths[declaration["id"]], metadata.get("value") or "")
+ if path is None:
+ return Resolution("unavailable", reason="membership_path_outside_root", evidence=module.evidence)
+ target = _target(index, path, fresh_ids)
+ return Resolution(target.status, target.target_id, target.reason,
+ tuple(dict.fromkeys(module.evidence + target.evidence)), target.candidates)
+
+
+def _resource(index, declaration, metadata, fresh_ids):
+ path = literal_path(index.paths[declaration["id"]], metadata.get("value") or "")
+ if path is None or path != metadata.get("target_path"):
+ return Resolution("unavailable", reason="membership_path_outside_root_or_conflicting")
+ result = index.resource_index.resolve_url(metadata.get("logical_url") or "")
+ if result.status != "resolved":
+ return result
+ accepted = _target(index, path, fresh_ids)
+ if accepted.target_id != result.target_id:
+ return Resolution(accepted.status if accepted.status != "resolved" else "ambiguous",
+ reason=accepted.reason or "membership_resource_target_conflict",
+ evidence=result.evidence, candidates=accepted.candidates)
+ return Resolution("resolved", result.target_id, evidence=result.evidence)
+
+
+def _remove_owned_edges(edges, site_id):
+ # Only this resolver's mechanisms are replaced; semantic/user relationships
+ # incident on the same accepted identity keep their independent ownership.
+ edges[:] = [edge for edge in edges if not (edge.get("context") in _CONTEXTS
+ and (edge.get("source") == site_id or edge.get("target") == site_id))]
+
+
+def _accepted_input_source(path: Path | str, root: Path) -> str | None:
+ """Compare the physically admitted walked owner without lending target roles."""
+ # Native/root spellings canonicalize, while separately discovered aliases
+ # stay distinct. Exact lexical equality rejects facts borrowed from another
+ # owner even when those inputs happen to share a contained physical target.
+ try:
+ return relative_qml_source(Path(path), root)
+ except (OSError, RuntimeError, ValueError):
+ return None
+
+
+def resolve_project_memberships(results, nodes, edges, *, root: Path, project_index,
+ fresh_ast_ids=()):
+ """Append fresh source-owned sites and replace stale derived scratch records.
+
+ Return fresh nodes/edges explicitly: a replacement of borrowed context may
+ occur before a caller's append-count boundary. Input dictionaries stay
+ immutable; the caller owns scratch publication and durable failure policy.
+ """
+ fresh_ids = set(fresh_ast_ids)
+ declarations = []
+ for path, result in results.items():
+ for node in list(result.get("nodes", [])):
+ md = qml_metadata(node)
+ if md.get("kind") in {"qt_source", "resource_alias"}:
+ declarations.append((node["id"], path, result, node, md))
+ derived_nodes, derived_edges = [], []
+ for _, path, result, declaration, metadata in sorted(declarations, key=lambda item: item[0]):
+ file = source_path(declaration, Path(root))
+ if (file is None or file != _accepted_input_source(path, root)
+ or declaration["id"] not in project_index.nodes):
+ raise ValueError("QML_METADATA: membership declaration source is not accepted")
+ span = _span(metadata)
+ _location(declaration, span)
+ if not _ast(declaration, fresh_ids):
+ resolution = Resolution("unavailable", reason="membership_declaration_provenance_unavailable")
+ elif result.get("qml_failures") or result.get("error") or result.get("partial"):
+ resolution = Resolution("unsupported", reason="metadata_source_incomplete")
+ elif reason := _unsupported(metadata):
+ resolution = Resolution("unsupported", reason=reason)
+ elif metadata["kind"] == "qt_source":
+ resolution = _source(project_index, declaration, metadata, fresh_ids)
+ else:
+ resolution = _resource(project_index, declaration, metadata, fresh_ids)
+ key = hashlib.sha256(declaration["id"].encode("utf-8")).hexdigest()
+ site = fact_node(file, "membership_resolution", key, metadata.get("value") or "membership",
+ span["start_row"] + 1, span=span,
+ declaration_id=declaration["id"], declaration_kind=metadata["kind"],
+ source_kind=metadata.get("source_kind") or "resource",
+ module_key=metadata.get("module_key") or "",
+ logical_url=metadata.get("logical_url") or "", alias=metadata.get("alias") or "",
+ status=resolution.status, reason=resolution.reason,
+ diagnostic_code="QML-RESOLVE-001" if resolution.status != "resolved" else "",
+ evidence=list(resolution.evidence[:50]), candidates=list(resolution.candidates[:50]),
+ target_id=resolution.target_id or "")
+ owned = [fact_edge(declaration["id"], site["id"], "contains", declaration,
+ "qt_membership_site", span=span, declaration_id=declaration["id"])]
+ if resolution.target_id:
+ owned.append(fact_edge(site["id"], resolution.target_id, "references", declaration,
+ "qt_project_source" if metadata["kind"] == "qt_source" else "qt_resource_membership",
+ span=span, declaration_id=declaration["id"], module_key=metadata.get("module_key") or "",
+ logical_url=metadata.get("logical_url") or "", evidence=list(resolution.evidence[:50])))
+ # Replacing a dictionary in scratch cannot mutate the previously saved
+ # context object. Canonical source declarations are never rewritten.
+ nodes[:] = [node for node in nodes if node.get("id") != site["id"]]
+ _remove_owned_edges(edges, site["id"])
+ nodes.append(site)
+ edges.extend(owned)
+ result["nodes"] = [node for node in result.get("nodes", []) if node.get("id") != site["id"]] + [site]
+ _remove_owned_edges(result.setdefault("edges", []), site["id"])
+ result["edges"].extend(owned)
+ derived_nodes.append(site)
+ derived_edges.extend(owned)
+ return derived_nodes, derived_edges
diff --git a/graphify/qt_qml_access_index.py b/graphify/qt_qml_access_index.py
new file mode 100644
index 0000000000..297a1c1d29
--- /dev/null
+++ b/graphify/qt_qml_access_index.py
@@ -0,0 +1,265 @@
+"""Accepted component, objectName and source-local handle lookup for Qt access."""
+from __future__ import annotations
+
+import base64
+
+from graphify.extractors.qt_cpp_identity import source_reference_key
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qml_resolution import build_qml_index
+from graphify.qml_resolution_types import Resolution, answer, qml_metadata, source_path
+from graphify.qt_project_index import QtProjectIndex
+from graphify.qt_qml_bridge import build_qt_qml_bridge
+
+
+class QtQmlAccessIndex:
+ def __init__(self, nodes, edges, *, root, project_index=None):
+ self.nodes = {node["id"]: node for node in nodes}
+ self.project = project_index or QtProjectIndex(nodes, edges, root=root)
+ self.native = build_qt_qml_bridge(nodes, edges, root=root, project_index=self.project)
+ self.qml = build_qml_index(nodes, edges, root=root, native_index=self.native, project_index=self.project)
+ self.loads, self.handles, self.names = {}, {}, {}
+ self.declarations, self.unsafe_parent_components, self.qobject_types = {}, set(), {}
+ self.native_classes, self.native_ancestry, self.qobject_reasons = {}, {}, {}
+ for node in self.native.nodes.values():
+ md = qt_metadata(node)
+ if md.get("kind") == "class" and md.get("class_name"):
+ self.native_classes.setdefault(md["class_name"], []).append((node, md))
+ for edge in edges:
+ child = self.qml.nodes.get(edge.get("target"), {})
+ md = qml_metadata(edge)
+ if (edge.get("relation") == "contains" and edge.get("confidence") == "EXTRACTED"
+ and md.get("kind") == "declaration" and md.get("span") == qml_metadata(child).get("span")
+ and source_path(edge, self.qml.root) == source_path(child, self.qml.root)):
+ self.declarations.setdefault(edge["target"], set()).add(edge["source"])
+ for node in self.qml.nodes.values():
+ metadata = qml_metadata(node)
+ if metadata.get("construction_parent_dynamic"):
+ self.mark_parent_mutation(node["id"])
+ if metadata.get("kind") in {"property", "binding"} and metadata.get("raw_name") == "objectName" and isinstance(metadata.get("literal_value"), str):
+ path = self.qml.paths[node["id"]]
+ key = (path, metadata.get("component_key"), metadata["literal_value"])
+ self.names.setdefault(key, set()).update(self.qml.objects.get((path, metadata.get("component_key"), metadata.get("object_scope_key")), []))
+
+ def load(self, metadata):
+ # Literal text alone cannot authorize a rejected SDK overload/owner.
+ if metadata.get("loader_supported") is False:
+ return Resolution("unsupported", reason=metadata.get("loader_reason") or "loader_overload_unestablished")
+ if metadata.get("conditional"):
+ return Resolution("dynamic", reason="conditional_component_load")
+ if metadata.get("operation") == "loadFromModule":
+ if not isinstance(metadata.get("module_uri"), str) or not isinstance(metadata.get("type_name"), str):
+ return Resolution("dynamic", reason="computed_module_or_type")
+ return self.project.resolve_component(metadata["module_uri"], metadata["type_name"])
+ if not isinstance(metadata.get("literal_url"), str):
+ return Resolution("dynamic", reason="computed_url")
+ return self.project.resolve_url(metadata["literal_url"], metadata.get("base_url"))
+
+ def root_object(self, component_id):
+ component = self.nodes.get(component_id, {})
+ metadata = qml_metadata(component)
+ if metadata.get("kind") == "file":
+ candidates = [node["id"] for node in self.nodes.values() if node.get("source_file") == component.get("source_file") and qml_metadata(node).get("kind") == "component"]
+ result = answer(candidates, reason="component_unavailable")
+ return self.root_object(result.target_id) if result.target_id else result
+ candidates = [node["id"] for node in self.nodes.values() if node.get("source_file") == component.get("source_file")
+ and qml_metadata(node).get("kind") == "object" and qml_metadata(node).get("component_key") == metadata.get("component_key")
+ and not qml_metadata(node).get("parent_scope_key")]
+ return answer(candidates, reason="component_root_unavailable")
+
+ def mark_parent_mutation(self, object_id):
+ """Invalidate only the accepted component containing a proved mutation."""
+ node = self.qml.nodes.get(object_id)
+ if node is not None:
+ self.unsafe_parent_components.add((self.qml.paths[object_id], qml_metadata(node).get("component_key")))
+
+ def _native_ancestry(self, name, seen=()):
+ """Prove non-widget ancestry from accepted canonical base facts only.
+
+ Q_OBJECT establishes metaobject capability, not QObject parenting: Qt's
+ creator treats QWidget instances specially. Unknown SDK bases/aliases
+ cannot prove that this exception is absent. A complete base-free mixin
+ is non-widget but supplies no QObject ancestry of its own.
+ """
+ if name in seen or len(seen) >= 32:
+ return False, False
+ if name in self.native_ancestry:
+ return self.native_ancestry[name]
+ records = self.native_classes.get(name)
+ if records is None:
+ return (True, True) if name == "QObject" else (False, False)
+ definitions = [(node, md) for node, md in records if md.get("is_definition") is True]
+ result = False, False
+ if len(definitions) == 1:
+ node, md = definitions[0]
+ target = self.nodes.get(md.get("class_id"), {})
+ bases = md.get("canonical_base_names")
+ if (self._native_class_proof(node, md, target)
+ and md.get("canonical_base_status") == "resolved" and isinstance(bases, list)
+ and len(bases) == len(md.get("bases", [])) <= 50
+ and all(isinstance(base, str) and base for base in bases)):
+ proofs = [self._native_ancestry(base, seen + (name,)) for base in bases]
+ result = all(safe for safe, _ in proofs), any(qobject for _, qobject in proofs)
+ self.native_ancestry[name] = result
+ return result
+
+ def _native_class_proof(self, node, md, target):
+ cpp = target.get("metadata", {}).get("cpp_class", {})
+ encoded = cpp.get("qualified_name_b64")
+ if not isinstance(encoded, str) or len(encoded) > 512:
+ return False
+ try:
+ qualified = base64.b64decode(encoded, validate=True).decode("utf-8")
+ except (ValueError, UnicodeError):
+ return False
+ return (node.get("_origin") == "ast" and md.get("status") == "resolved"
+ and target.get("_callable_class") is True and md.get("generic_target_id") == target.get("id")
+ and cpp.get("contract_version") == 1 and cpp.get("is_definition") is True and not cpp.get("ambiguous")
+ and qualified == md.get("class_name") and cpp.get("span") == md.get("span")
+ and source_path(node, self.qml.root) == source_path(target, self.qml.root))
+
+ def _native_parent_type(self, provider_id):
+ class_id = self.native.metadata(provider_id).get("class_id")
+ names = {name for name, records in self.native_classes.items()
+ if any(md.get("class_id") == class_id for _, md in records)}
+ return len(names) == 1 and all(self._native_ancestry(next(iter(names))))
+
+ def _qobject_type(self, object_id, seen=()):
+ """Admit bounded Qt object types or a unique source-defined base chain.
+
+ An unknown custom class, gadget or widget cannot inherit ownership from
+ its spelling. Local providers precede the bounded builtin import view.
+ """
+ if object_id in seen or len(seen) >= 32:
+ return False
+ if object_id in self.qobject_types:
+ return self.qobject_types[object_id]
+ md = qml_metadata(self.qml.nodes[object_id])
+ path, name = self.qml.paths[object_id], md.get("type_name") or ""
+ result = self.qml.resolve_type(path, md.get("component_key") or "", name)
+ if result.target_id:
+ if self.native.is_provider(result.target_id):
+ # This view admits only complete, accepted QObject definitions
+ # with a literal registration and source/build module evidence.
+ provider = self.native.metadata(result.target_id)
+ accepted = bool(provider.get("creatable") and not provider.get("singleton")
+ and self._native_parent_type(result.target_id))
+ if not accepted:
+ self.qobject_reasons[object_id] = "native_construction_ancestry_unestablished"
+ else:
+ roots = self.qml._root_objects(result.target_id)
+ accepted = len(roots) == 1 and self._qobject_type(roots[0], seen + (object_id,))
+ elif result.status == "ambiguous":
+ accepted = False
+ else:
+ parts = name.split(".")
+ accepted = False
+ for import_id in self.qml.imports.get(path, []):
+ imp = qml_metadata(self.qml.nodes[import_id])
+ qualifier = imp.get("qualifier") or ""
+ if imp.get("import_kind") != "module" or qualifier != (parts[0] if len(parts) == 2 else ""):
+ continue
+ module, type_name = imp.get("value"), parts[-1]
+ accepted |= (module in {"QtQml", "QtQuick"} and type_name in {"QtObject", "Component", "Timer", "Connections"}
+ or module == "QtQuick" and type_name in {"Item", "Rectangle", "Text", "Image", "MouseArea"})
+ self.qobject_types[object_id] = accepted
+ return accepted
+
+ def _construction_parent(self, child_id):
+ child, md = self.qml.nodes[child_id], qml_metadata(self.qml.nodes[child_id])
+ scope = md.get("construction_parent_scope_key")
+ if "construction_parent_scope_key" not in md:
+ return Resolution("unavailable", reason="construction_parent_evidence_missing")
+ if not scope:
+ return Resolution("unavailable", reason="construction_root")
+ path, component = self.qml.paths[child_id], md.get("component_key")
+ parents = self.qml.objects.get((path, component, scope), [])
+ parent = answer(parents, reason="construction_parent_unavailable")
+ if not parent.target_id:
+ return parent
+ owner_ids = self.declarations.get(child_id, set())
+ if len(owner_ids) != 1:
+ return Resolution("unavailable", reason="construction_owner_unestablished")
+ owner = self.qml.nodes.get(next(iter(owner_ids)), {})
+ parent_node = self.qml.nodes[parent.target_id]
+ omd, pmd = qml_metadata(owner), qml_metadata(parent_node)
+ role = md.get("construction_parent_kind")
+ owner_kind = "property" if role == "property" else "object" if role in {"default", "binding"} else ""
+ if (child.get("_origin") != "ast" or owner.get("_origin") != "ast" or parent_node.get("_origin") != "ast"
+ or omd.get("kind") != owner_kind or omd.get("object_scope_key") != scope
+ or omd.get("component_key") != component or source_path(owner, self.qml.root) != path
+ or not self._qobject_type(child_id) or not self._qobject_type(parent.target_id)
+ or not (omd.get("span", {}).get("start_byte", -1) < md.get("span", {}).get("start_byte", -1)
+ and md.get("span", {}).get("end_byte", -1) <= omd.get("span", {}).get("end_byte", -1))
+ or not (pmd.get("span", {}).get("start_byte", -1) < md.get("span", {}).get("start_byte", -1)
+ and md.get("span", {}).get("end_byte", -1) <= pmd.get("span", {}).get("end_byte", -1))):
+ reason = next((self.qobject_reasons[nid] for nid in (child_id, parent.target_id)
+ if nid in self.qobject_reasons), "construction_parent_unestablished")
+ return Resolution("unavailable", reason=reason)
+ return Resolution("resolved", parent.target_id, evidence=(child_id, next(iter(owner_ids)), parent.target_id))
+
+ def _descendant(self, target, receiver, direct):
+ seen, evidence = set(), set()
+ while target not in seen and len(seen) < 256:
+ seen.add(target)
+ parent = self._construction_parent(target)
+ if parent.reason == "construction_root":
+ return False, evidence, ""
+ if parent.target_id is None:
+ return None, evidence, parent.reason
+ evidence.update(parent.evidence)
+ if parent.target_id == receiver:
+ return True, evidence, ""
+ if direct:
+ return False, evidence, ""
+ target = parent.target_id
+ return None, evidence, "construction_cycle_or_limit"
+
+ def find_child(self, root_id, name, *, options="recursive"):
+ """Search the evidenced construction subtree, never component-wide names."""
+ if options not in {"recursive", "direct"}:
+ return Resolution("unsupported", reason="find_child_options_unsupported")
+ if root_id not in self.qml.nodes or qml_metadata(self.qml.nodes[root_id]).get("kind") != "object":
+ return Resolution("unavailable", reason="qml_receiver_unestablished")
+ md = qml_metadata(self.qml.nodes[root_id])
+ key = self.qml.paths[root_id], md.get("component_key")
+ candidates = self.names.get((*key, name), set()) - {root_id}
+ if candidates and key in self.unsafe_parent_components:
+ return Resolution("dynamic", reason="component_parenting_mutated", candidates=tuple(sorted(candidates))[:50])
+ resolved, uncertain, evidence, reasons = [], [], set(), set()
+ for target in sorted(candidates):
+ inside, proof, reason = self._descendant(target, root_id, options == "direct")
+ evidence.update(proof)
+ if inside:
+ resolved.append(target)
+ elif inside is None:
+ uncertain.append(target)
+ reasons.add(reason)
+ if uncertain:
+ reason = "native_construction_ancestry_unestablished" if "native_construction_ancestry_unestablished" in reasons else "object_parenting_unestablished"
+ return Resolution("unavailable", reason=reason, candidates=tuple(sorted(resolved + uncertain))[:50])
+ return answer(resolved, reason="object_name_unavailable", evidence=tuple(sorted(evidence))[:50])
+
+ def member(self, object_id, name, operation):
+ if object_id not in self.qml.nodes or qml_metadata(self.qml.nodes[object_id]).get("kind") != "object":
+ return Resolution("unavailable", reason="qml_receiver_unestablished")
+ result = self.qml.follow_member(object_id, [name])
+ if not result.target_id:
+ return result
+ metadata = self.qml.md(result.target_id)
+ kind = metadata.get("kind")
+ supported = {"function", "signal"} if operation == "invokeMethod" else {"property"}
+ if operation in {"setProperty", "write"} and "readonly" in metadata.get("modifiers", []):
+ return Resolution("unsupported", reason="readonly_property")
+ return result if kind in supported else Resolution("unavailable", reason="member_role_mismatch")
+
+ def handle(self, metadata, reference=None):
+ key = source_reference_key(metadata)
+ entry = self.handles.get(key) if key else None
+ if entry is None:
+ status = metadata.get("receiver_identity_status")
+ return Resolution(status if status in {"dynamic", "ambiguous", "unsupported"} else "unavailable",
+ reason=metadata.get("receiver_identity_reason") or "qml_handle_unestablished")
+ target, assignment, condition = entry
+ if condition or assignment != metadata.get("receiver_assignment_byte", -1):
+ return Resolution("dynamic", reason="conditional_or_reassigned_handle")
+ return Resolution("resolved", target, evidence=("source_handle",))
diff --git a/graphify/qt_qml_access_resolution.py b/graphify/qt_qml_access_resolution.py
new file mode 100644
index 0000000000..677f22a7b7
--- /dev/null
+++ b/graphify/qt_qml_access_resolution.py
@@ -0,0 +1,103 @@
+"""Resolve literal C++ access to accepted QML source, never instantiate components."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import qt_edge, qt_metadata, update_qt
+from graphify.extractors.qt_cpp_identity import source_reference_key
+from graphify.qml_resolution_types import Resolution
+from graphify.qml_resolution_types import qml_metadata
+from graphify.qt_qml_access_index import QtQmlAccessIndex
+from graphify.qt_qml_event_access import resolve_qml_event_access
+from graphify.qt_context_bindings import project_context_access, resolve_context_bindings
+from graphify.qt_qml_bridge import build_qt_qml_bridge
+
+
+def _annotate(site, resolution):
+ update_qt(site, status=resolution.status, target_id=resolution.target_id or "", reason=resolution.reason,
+ candidates=list(resolution.candidates), evidence=list(resolution.evidence), bridge_direction="cpp_to_qml")
+
+
+def resolve_qt_qml_access(per_file, all_nodes, all_edges, *, root, project_index=None):
+ """Only fresh facts are annotated; prior accepted dictionaries are lookup context."""
+ index = QtQmlAccessIndex(all_nodes, all_edges, root=root, project_index=project_index)
+ results = list(per_file.values() if isinstance(per_file, dict) else per_file)
+ sites = sorted([(site, result) for result in results if result for site in result.get("nodes", [])
+ if qt_metadata(site).get("kind") in {"qml_load", "qml_root", "qml_access", "qml_parent_mutation", "context_exposure", "initial_properties"}],
+ key=lambda pair: (pair[0]["source_file"], qt_metadata(pair[0]).get("span", {}).get("start_byte", -1)))
+ for site, result in sites:
+ metadata, edges = qt_metadata(site), []
+ kind, operation = metadata["kind"], metadata.get("operation")
+ key = source_reference_key(metadata)
+ context = "qt_cpp_qml_load"
+ if kind == "qml_load":
+ if key:
+ resolution = index.load(metadata)
+ index.loads.setdefault(key, []).append(resolution)
+ else:
+ status = metadata.get("receiver_identity_status")
+ resolution = Resolution(status if status in {"dynamic", "ambiguous", "unsupported"} else "unavailable",
+ reason=metadata.get("receiver_identity_reason") or "loader_declaration_unestablished")
+ elif kind == "qml_root":
+ loads = index.loads.get(key, [])
+ # Creation-context rejection precedes otherwise valid source loading.
+ if metadata.get("loader_supported") is False:
+ resolution = Resolution("unsupported", reason=metadata.get("loader_reason") or "loader_overload_unestablished")
+ elif len(loads) != 1 or not loads[0].target_id:
+ resolution = Resolution("ambiguous" if len(loads) > 1 else "unavailable", reason="unique_source_loader_unestablished")
+ elif operation == "rootObjects" and not metadata.get("single_root_selection"):
+ resolution = Resolution("unsupported", reason="root_list_requires_static_selection")
+ else:
+ resolution = index.root_object(loads[0].target_id)
+ elif kind == "qml_parent_mutation":
+ # Only an established QML receiver invalidates its accepted tree;
+ # unrelated QObject mutations grant no component identity.
+ handle = index.handle(metadata)
+ if handle.target_id:
+ index.mark_parent_mutation(handle.target_id)
+ resolution = Resolution("dynamic", reason="runtime_parent_mutation")
+ elif kind == "qml_access" and metadata.get("reflection_supported") is False:
+ # A valid QObject handle cannot lend SDK authority to a source
+ # reflection class. Rejection must precede member/handle seeding.
+ resolution = Resolution("unsupported", reason="reflection_api_type_unestablished")
+ elif kind == "qml_access":
+ if metadata.get("handle_role") == "root":
+ loads = index.loads.get(key, [])
+ handle = index.root_object(loads[0].target_id) if len(loads) == 1 and loads[0].target_id else Resolution("unavailable", reason="unique_source_loader_unestablished")
+ else:
+ handle = index.handle(metadata)
+ if not handle.target_id:
+ resolution = handle
+ elif operation in {"read", "write"} and metadata.get("receiver_type") == "QQmlProperty" and qml_metadata(index.nodes[handle.target_id]).get("kind") == "property":
+ resolution = handle
+ context = "qt_cpp_qml_property_write" if operation == "write" else "qt_cpp_qml_property_read"
+ elif not isinstance(metadata.get("lookup_name"), str):
+ resolution = Resolution("dynamic", reason="computed_access_name")
+ elif operation == "findChild":
+ resolution = (index.find_child(handle.target_id, metadata["lookup_name"],
+ options=metadata.get("find_child_options", "unsupported"))
+ if metadata.get("find_child_type_supported") else
+ Resolution("unsupported", reason="find_child_type_unsupported"))
+ context = "qt_cpp_qml_find_child"
+ else:
+ resolution = index.member(handle.target_id, metadata["lookup_name"], operation)
+ context = "qt_cpp_qml_invoke" if operation == "invokeMethod" else "qt_cpp_qml_property_write" if operation in {"setProperty", "write"} else "qt_cpp_qml_property_read"
+ else:
+ # Provider names alone do not establish a runtime engine/component
+ # binding. Preserve the literal exposure for the bridge profile.
+ resolution = Resolution("unavailable", reason="provider_component_scope_unestablished")
+ context = "qt_context_exposure" if kind == "context_exposure" else "qt_initial_property"
+ _annotate(site, resolution)
+ if resolution.target_id:
+ relation = "calls" if operation == "invokeMethod" and index.qml.md(resolution.target_id).get("kind") == "function" else "uses"
+ edges.append(qt_edge(site, resolution.target_id, relation, context, bridge_direction="cpp_to_qml"))
+ assigned = source_reference_key(metadata, "assigned")
+ if assigned and kind in {"qml_root", "qml_access"}:
+ index.handles[assigned] = (resolution.target_id, metadata.get("assignment_byte", -1), bool(metadata.get("conditional")))
+ known = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])}
+ additions = [edge for edge in edges if (edge["source"], edge["target"], edge.get("context")) not in known]
+ result.setdefault("edges", []).extend(additions)
+ if all_edges is not result["edges"]:
+ all_edges.extend(additions)
+ bindings = resolve_context_bindings(results, index, all_nodes, all_edges)
+ bridge = build_qt_qml_bridge(all_nodes, all_edges, root=root, project_index=index.project)
+ project_context_access(results, bridge, bindings, all_nodes, all_edges, index.qml)
+ resolve_qml_event_access(results, index, all_nodes, all_edges)
diff --git a/graphify/qt_qml_bridge.py b/graphify/qt_qml_bridge.py
new file mode 100644
index 0000000000..4fbf58fb34
--- /dev/null
+++ b/graphify/qt_qml_bridge.py
@@ -0,0 +1,113 @@
+"""Scoped Qt native providers for QML indexes; no guessed global class aliases."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qml_resolution_types import Resolution, answer, source_path
+from graphify.qt_qml_bridge_members import QtMemberViews
+
+
+class QtQmlBridgeIndex(QtMemberViews):
+ """Build one source/provider table over an already accepted corpus."""
+
+ def __init__(self, nodes, edges=(), *, root: Path, project_index=None):
+ nodes = list(nodes)
+ self.nodes = {node["id"]: node for node in nodes if node.get("id") and source_path(node, Path(root)) is not None}
+ self.provider_records, self.modules, self.unresolved, self.module_bounds = {}, {}, {}, {}
+ super().__init__(self.nodes.values())
+ classes = {}
+ for node in self.nodes.values():
+ md = qt_metadata(node)
+ if md.get("kind") == "class" and md.get("class_id") and md.get("is_definition") is True:
+ classes.setdefault(md["class_id"], []).append(md)
+ for node in self.nodes.values():
+ md = qt_metadata(node)
+ if md.get("kind") != "registration":
+ continue
+ value = dict(md)
+ value["evidence"] = (node["id"],)
+ if value.get("status") != "resolved" or value.get("class_id") not in self.nodes:
+ self.unresolved[node["id"]] = value
+ continue
+ if value.get("module_required"):
+ context = project_index.module_context(value["module_source_file"]) if project_index else None
+ if project_index is None or context is None or context.target_id is None:
+ value.update(status=context.status if context else "unavailable",
+ reason=context.reason if context else "module_metadata_unavailable")
+ self.unresolved[node["id"]] = value
+ continue
+ module = project_index.module_metadata(context.target_id)
+ value.update(uri=module.get("uri"), major=module.get("major"), minor=module.get("minor"),
+ evidence=value["evidence"] + context.evidence + (context.target_id,))
+ if isinstance(value["major"], int) and isinstance(value["minor"], int):
+ value["module_minor"] = value["minor"]
+ added = value.get("added_version") or [value["major"], 0]
+ value["minor"] = added[1] if added[0] == value["major"] else 0
+ if not value.get("uri") or not isinstance(value.get("major"), int) or not isinstance(value.get("minor"), int):
+ value.update(status="unavailable", reason="literal_module_version_unavailable")
+ self.unresolved[node["id"]] = value
+ continue
+ observed = classes.get(value["class_id"], [])
+ if not observed:
+ value.update(status="unavailable", reason="native_class_definition_unavailable")
+ self.unresolved[node["id"]] = value
+ continue
+ if not any(item.get("is_qobject") for item in observed):
+ value.update(status="unsupported", reason="native_class_metaobject_unavailable")
+ self.unresolved[node["id"]] = value
+ continue
+ # Version bounds authorize only accepted native providers. Rejected
+ # gadgets or missing classes cannot create a namespace with no type.
+ if "module_minor" in value:
+ self.module_bounds.setdefault(value["uri"], set()).add((value["major"], value["module_minor"]))
+ self.provider_records[node["id"]] = value
+ self.modules.setdefault(value["uri"], []).append(node["id"])
+
+ def _version(self, uri, major, minor):
+ ids = self.modules.get(uri, [])
+ versions = {(self.provider_records[nid]["major"], self.provider_records[nid]["minor"]) for nid in ids}
+ bounds = self.module_bounds.get(uri, set())
+ versions |= bounds
+ if not versions:
+ return None
+ if major is None:
+ return max(versions)
+ if minor is None:
+ choices = [version for version in versions if version[0] == major]
+ return max(choices) if choices else None
+ return (major, minor) if (major, minor) in versions or any(ma == major and minor <= maximum for ma, maximum in bounds) else None
+
+ def module_import(self, uri, major=None, minor=None):
+ """Multiple native types in one module provide one namespace, not ambiguity."""
+ version = self._version(uri, major, minor)
+ if version is None:
+ return Resolution("unavailable", reason="native_module_version_unavailable")
+ ids = [nid for nid in self.modules[uri] if self.provider_records[nid]["major"] == version[0]]
+ evidence = tuple(sorted({item for nid in ids for item in self.provider_records[nid]["evidence"]}))
+ # This ID is namespace evidence only; type lookup independently checks exports.
+ return Resolution("resolved", sorted(ids)[0], evidence=evidence)
+
+ def module_type(self, uri, major, minor, name, *, importer="", **kwargs):
+ version = self._version(uri, major, minor)
+ if version is None:
+ return Resolution("unavailable", reason="native_module_version_unavailable")
+ candidates = []
+ for nid in self.modules[uri]:
+ md = self.provider_records[nid]
+ added = tuple(md.get("added_version") or (md["major"], 0))
+ removed = tuple(md["removed_version"]) if md.get("removed_version") else None
+ if md.get("raw_name") != name or md.get("anonymous") or md["major"] != version[0]:
+ continue
+ if md["minor"] > version[1] or added > version or (removed and version >= removed):
+ continue
+ candidates.append(nid)
+ if candidates:
+ latest = max(self.provider_records[nid]["minor"] for nid in candidates)
+ candidates = [nid for nid in candidates if self.provider_records[nid]["minor"] == latest]
+ evidence = tuple(sorted({item for nid in candidates for item in self.provider_records[nid]["evidence"]}))
+ return answer(candidates, reason="native_type_unavailable", evidence=evidence)
+
+
+def build_qt_qml_bridge(nodes, edges=(), *, root, project_index=None):
+ return QtQmlBridgeIndex(nodes, edges, root=Path(root), project_index=project_index)
diff --git a/graphify/qt_qml_bridge_members.py b/graphify/qt_qml_bridge_members.py
new file mode 100644
index 0000000000..4c59f18d6c
--- /dev/null
+++ b/graphify/qt_qml_bridge_members.py
@@ -0,0 +1,137 @@
+"""Read-only native member views with overload ambiguity before ID deduplication."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qml_resolution_types import Resolution
+
+
+class QtMemberViews:
+ """QML lookups borrow canonical C++ methods and distinct Qt property facts."""
+ nodes: dict[str, dict]
+ provider_records: dict[str, dict]
+
+ def __init__(self, nodes):
+ self.native_members, self.native_views, self.native_proofs = {}, {}, {}
+ self.notify_accessors = {}
+ for node in nodes:
+ md = qt_metadata(node)
+ if md.get("kind") in {"property", "member"} and md.get("class_id"):
+ self.native_members.setdefault((md["class_id"], md.get("raw_name")), []).append(node)
+ if md.get("kind") == "property_accessor" and md.get("accessor_role") == "notify":
+ self.notify_accessors.setdefault(md.get("property_id"), []).append(node)
+
+ def member(self, provider_id, name):
+ provider = self.provider_records.get(provider_id)
+ if provider is None:
+ return Resolution("unavailable", reason="native_provider_unknown")
+ class_id = provider["class_id"]
+ declarations = self.native_members.get((class_id, name), [])
+ properties = [node for node in declarations if qt_metadata(node).get("kind") == "property"]
+ methods = [node for node in declarations if qt_metadata(node).get("kind") == "member"
+ and qt_metadata(node).get("access") == "public"
+ and set(qt_metadata(node).get("roles", [])) & {"invokable", "signal", "slot"}]
+ signatures = {qt_metadata(node).get("signature") for node in methods}
+ if len(properties) > 1 or len(signatures) > 1 or (properties and methods):
+ return Resolution("ambiguous", reason="native_member_or_overload_ambiguous",
+ candidates=tuple(sorted(node["id"] for node in properties + methods))[:50])
+ selected = properties[0] if properties else methods[0] if methods else None
+ if selected is None:
+ return Resolution("unavailable", reason="native_member_not_qml_visible")
+ md = qt_metadata(selected)
+ if md.get("revision") or md.get("revisions"):
+ return Resolution("unsupported", reason="native_member_revision_requires_supported_version")
+ if md.get("status") != "resolved":
+ return Resolution(md.get("status") or "unavailable", reason=md.get("reason") or "native_member_unavailable")
+ target = selected["id"] if properties else md.get("generic_target_id")
+ if not target or target not in self.nodes:
+ return Resolution("unavailable", reason="canonical_native_member_unavailable")
+ role = "property" if properties else "signal" if "signal" in md.get("roles", []) else "function"
+ self.native_views[target] = {"kind": role, "raw_name": name, "raw_type": md.get("raw_type") or md.get("return_type", ""),
+ "class_id": class_id, "qt_native": True, "span": md["span"],
+ "source_file": selected["source_file"], "generic_target_id": target,
+ "provider_id": provider_id}
+ if role == "signal":
+ self.native_views[target]["parameter_names"] = list(md.get("parameter_names", []))
+ evidence = tuple(sorted(set(provider["evidence"] + (provider_id, selected["id"]))))
+ self.native_proofs[(target, provider_id)] = {"class_id": class_id, "member_fact_id": selected["id"],
+ "canonical_target_id": target, "provider_id": provider_id,
+ "evidence": list(evidence), "kind": role}
+ return Resolution("resolved", target, evidence=evidence)
+
+ def property_notify(self, property_id, provider_id):
+ """QML property-change spelling projects to the proven C++ NOTIFY signal.
+
+ A property never fabricates a native signal. Its accepted accessor fact
+ and public signal view must agree on the same canonical endpoint, class
+ and source span; ambiguity or missing notification remains explicit.
+ """
+ provider, prop = self.provider_records.get(provider_id), self.nodes.get(property_id)
+ if provider is None or prop is None:
+ return Resolution("unavailable", reason="native_property_notify_unavailable")
+ md = qt_metadata(prop)
+ if md.get("kind") != "property" or md.get("class_id") != provider["class_id"] or md.get("owner_id") != provider["class_id"]:
+ return Resolution("unavailable", reason="native_property_notify_provenance_unavailable")
+ accepted = self.member(provider_id, md.get("raw_name"))
+ if accepted.status != "resolved" or accepted.target_id != property_id:
+ return accepted
+ if md.get("constant") or not isinstance(md.get("notify"), str):
+ return Resolution("unavailable", reason="native_property_has_no_notify_signal")
+ accessors = self.notify_accessors.get(property_id, [])
+ if len(accessors) != 1:
+ return Resolution("ambiguous" if accessors else "unavailable", reason="native_property_notify_accessor_not_unique",
+ candidates=tuple(sorted(node["id"] for node in accessors))[:50])
+ accessor, link = accessors[0], qt_metadata(accessors[0])
+ if (link.get("owner_id") != property_id or link.get("span") != md.get("span")
+ or accessor.get("source_file") != prop.get("source_file") or link.get("raw_name") != md["notify"]):
+ return Resolution("unavailable", reason="native_property_notify_provenance_unavailable")
+ if link.get("status") != "resolved":
+ return Resolution(link.get("status") or "unavailable", reason=link.get("reason") or "native_property_notify_unavailable")
+ signal = self.member(provider_id, md["notify"])
+ if signal.status != "resolved":
+ return signal
+ if (self.metadata(signal.target_id).get("kind") != "signal"
+ or signal.target_id != link.get("generic_target_id")):
+ return Resolution("unsupported", reason="native_property_notify_is_not_signal")
+ evidence = tuple(sorted(set(accepted.evidence + signal.evidence + (accessor["id"],))))
+ return Resolution("resolved", signal.target_id, "native_property_notify_signal", evidence)
+
+ def endpoint_proof(self, target_id, evidence=(), *, kind=None):
+ if target_id in self.provider_records and target_id in evidence:
+ provider = self.provider_records[target_id]
+ return {"kind": kind or "component", "provider_id": target_id, "canonical_target_id": target_id,
+ "class_id": provider["class_id"], "evidence": list(evidence)}
+ proofs = [proof for (target, _), proof in self.native_proofs.items() if target == target_id
+ and proof["member_fact_id"] in evidence and proof["provider_id"] in evidence]
+ if len(proofs) != 1:
+ return {}
+ # Endpoint role and occurrence-specific lookup evidence have separate
+ # owners. Do not overwrite the shared signal proof when two properties
+ # use one NOTIFY endpoint; copy the current accepted accessor evidence.
+ proof = dict(proofs[0])
+ proof["evidence"] = list(dict.fromkeys(proof["evidence"] + [item for item in evidence if item in self.nodes]))
+ return proof
+
+ def metadata(self, target_id):
+ if isinstance(target_id, dict):
+ target_id = target_id.get("id")
+ if target_id in self.provider_records:
+ md = self.provider_records[target_id]
+ return {"kind": "component", "raw_name": md.get("raw_name"), "singleton": md.get("singleton", False),
+ "class_id": md["class_id"], "qt_native": True, "creatable": md.get("creatable", False)}
+ return dict(self.native_views.get(target_id, {}))
+
+ def is_provider(self, target_id):
+ return target_id in self.provider_records
+
+ def context_member(self, binding_node, name):
+ """A source/engine-scoped context provider does not require a type export."""
+ md = qt_metadata(binding_node)
+ class_id = md.get("provider_target_id")
+ if md.get("kind") != "context_binding" or md.get("status") != "resolved" or class_id not in self.nodes:
+ return Resolution("unavailable", reason="context_provider_provenance_unavailable")
+ nid = binding_node["id"]
+ if nid not in self.nodes or md.get("component_target_id") not in self.nodes:
+ return Resolution("unavailable", reason="context_component_provenance_unavailable")
+ self.provider_records[nid] = {"class_id": class_id, "raw_name": md.get("exposed_name") or "",
+ "evidence": tuple(md.get("evidence") or ()) + (nid,)}
+ return self.member(nid, name)
diff --git a/graphify/qt_qml_callback_proof.py b/graphify/qt_qml_callback_proof.py
new file mode 100644
index 0000000000..3564c53a2e
--- /dev/null
+++ b/graphify/qt_qml_callback_proof.py
@@ -0,0 +1,110 @@
+"""Revalidate subscription callbacks against borrowed, source-owned QML facts.
+
+The ordinary QML index owns nonlexical lookup. Captured JS binders remain separate
+authority; neither a matching label nor another function in the component repairs
+a changed subscription. No corpus source is read or executed at this boundary.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.qml_scope import QmlProjectIndex
+
+
+def _span(node):
+ value = qml_metadata(node).get("span", {})
+ fields = ("start_byte", "end_byte", "start_row", "end_row", "start_column", "end_column")
+ return value if (isinstance(value, dict) and all(type(value.get(key)) is int and value[key] >= 0 for key in fields)
+ and value["start_byte"] < value["end_byte"] and value["start_row"] <= value["end_row"]) else {}
+
+
+def _within(node, owner):
+ span, enclosing = _span(node), _span(owner)
+ return bool(span and enclosing and enclosing["start_byte"] <= span["start_byte"]
+ and span["end_byte"] <= enclosing["end_byte"])
+
+
+def _ancestors(source, nodes):
+ """Follow bounded lexical owners, checking source/scope containment at each hop."""
+ result, child = [], source
+ while qml_metadata(child).get("owner_id"):
+ identity = qml_metadata(child)["owner_id"]
+ if identity in result or identity not in nodes or len(result) >= 32:
+ return None
+ owner = nodes[identity]
+ md, parent = qml_metadata(child), qml_metadata(owner)
+ if (owner.get("source_file") != source.get("source_file") or not _within(child, owner)
+ or md.get("component_key") != parent.get("component_key")
+ or md.get("object_scope_key") != parent.get("object_scope_key")):
+ return None
+ result.append(identity)
+ child = owner
+ return result
+
+
+def valid_subscription_callback(subscription, metadata, nodes):
+ """Bind a derived site to its original call or its exact Connections handler."""
+ # Mapping keys carry identity after JSON reload; producer dictionaries carry
+ # it directly. Borrow a normalized view without changing either representation.
+ by_id = {identity: {**node, "id": identity} for identity, node in nodes.items()}
+ owner_id, handler_id = metadata.get("owner_id"), metadata.get("handler_target_id")
+ if owner_id not in by_id or handler_id not in by_id:
+ return False
+ source, handler = by_id[owner_id], by_id[handler_id]
+ md, hmd = qml_metadata(source), qml_metadata(handler)
+ if (md.get("contract_version") != 1 or hmd.get("contract_version") != 1
+ or source.get("source_file") != subscription.get("source_file")
+ or handler.get("source_file") != source.get("source_file")
+ or subscription.get("source_location") != source.get("source_location")
+ or metadata.get("span") != _span(source)
+ or hmd.get("component_key") != md.get("component_key")):
+ return False
+ if metadata.get("subscription_form") == "Connections":
+ enclosing = by_id.get(md.get("owner_id"), {})
+ short = str(md.get("raw_name") or "").rsplit(".", 1)[-1]
+ return (owner_id == handler_id and md.get("kind") == "handler" and md.get("connections") is True
+ and md.get("connection_supplied") is True and bool(md.get("connection_target"))
+ and not md.get("disabled_reason") and not md.get("attached_prefix")
+ and short.startswith("on") and short[2:3].isupper()
+ and md.get("reference") == short[2:3].lower() + short[3:]
+ and qml_metadata(enclosing).get("kind") == "object"
+ and qml_metadata(enclosing).get("type_name") == "Connections"
+ and _ancestors(source, by_id) is not None)
+ if (metadata.get("subscription_form") != "signal.connect" or md.get("kind") != "call"
+ or not isinstance(md.get("reference"), str) or not md["reference"].endswith(".connect")
+ or type(md.get("callback_argument_count")) is not int or md["callback_argument_count"] != 1
+ or not isinstance(md.get("callback_reference"), str) or not md["callback_reference"]
+ or hmd.get("kind") not in {"function", "js_function"}):
+ return False
+ owners = _ancestors(source, by_id)
+ if not owners:
+ return False
+ facts = [node for node in by_id.values() if node.get("source_file") == source.get("source_file")
+ and qml_metadata(node).get("contract_version") == 1]
+ index = QmlProjectIndex(facts, [], root=Path("."))
+ file = index.paths.get(owner_id)
+ # QML functions belong to an exact indexed object; local JS declarations
+ # borrow their lexical owner. Both must stay inside the original source span.
+ if hmd.get("kind") == "function":
+ containers = index.objects.get((index.paths.get(handler_id), hmd.get("component_key"),
+ hmd.get("object_scope_key")), [])
+ if len(containers) != 1 or not _within(handler, index.nodes[containers[0]]):
+ return False
+ elif not _ancestors(handler, by_id):
+ return False
+ callback = md["callback_reference"]
+ if md.get("callback_lexical_shadowed") is True:
+ # Local functions and recursion have source-AST binder IDs. A formal,
+ # reassigned variable or implicit native parameter has no callable binder.
+ parent = hmd.get("owner_id")
+ return (md.get("callback_lexical_target_id") == handler_id and callback == hmd.get("raw_name")
+ and hmd.get("object_scope_key") == md.get("object_scope_key")
+ and (handler_id in owners or parent in owners)
+ and (handler_id in owners or parent in by_id and _within(handler, by_id[parent])))
+ if md.get("callback_lexical_shadowed") is not False or md.get("callback_lexical_target_id"):
+ return False
+ # Resolve the literal callback in the original object/component scope. This
+ # uses the same production lookup as projection, rather than a global name scan.
+ return bool(file and index.resolve_member(file, md.get("component_key", ""),
+ md.get("object_scope_key", ""), callback).target_id == handler_id)
diff --git a/graphify/qt_qml_event_access.py b/graphify/qt_qml_event_access.py
new file mode 100644
index 0000000000..162b55eaf6
--- /dev/null
+++ b/graphify/qt_qml_event_access.py
@@ -0,0 +1,115 @@
+"""Explicit legacy meta-object connections across established C++/QML handles."""
+from __future__ import annotations
+
+import re
+
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.qml_resolution_types import Resolution, qml_metadata
+from graphify.qt_event_index import QtEventIndex, value_type
+from graphify.qt_event_resolution import _role
+
+
+def _qml_endpoint(index, metadata, prefix):
+ expression = metadata.get("signal" if prefix == "sender" else "receiver", {})
+ if expression.get("form") != "legacy":
+ return Resolution("unsupported", reason="qml_endpoint_requires_literal_meta_signature")
+ handle = index.handle({**metadata, "receiver_reference": metadata.get(prefix + "_reference"),
+ "receiver_declaration_id": metadata.get(prefix + "_declaration_id"),
+ "receiver_assignment_byte": metadata.get(prefix + "_assignment_byte", -1)})
+ if not handle.target_id:
+ return handle
+ result = index.member(handle.target_id, expression.get("member_name", ""), "invokeMethod")
+ if not result.target_id:
+ return result
+ declaration = qml_metadata(index.nodes[result.target_id])
+ if prefix == "sender" and declaration.get("kind") != "signal":
+ return Resolution("unavailable", reason="qml_sender_is_not_signal")
+ signature = declaration.get("signature", "").strip().strip("()")
+ parameters = []
+ names = {"int": "int", "real": "double", "double": "double", "bool": "bool", "string": "QString", "var": "QVariant"}
+ for value in signature.split(",") if signature else []:
+ match = re.fullmatch(r"\s*(\w+)\s+\w+\s*", value)
+ annotation = re.fullmatch(r"\s*\w+\s*:\s*(\w+)\s*", value)
+ untyped = re.fullmatch(r"\s*\w+\s*", value)
+ raw_type = match[1] if match else annotation[1] if annotation else "var" if untyped and declaration.get("kind") == "function" else ""
+ if raw_type not in names:
+ return Resolution("unsupported", reason="qml_meta_signature_unestablished")
+ parameters.append(names[raw_type])
+ if parameters != expression.get("parameter_types", []):
+ return Resolution("unavailable", reason="qml_meta_signature_mismatch")
+ return result
+
+
+def resolve_qml_event_access(results, index, all_nodes, all_edges):
+ native = QtEventIndex(all_nodes)
+ for result in results:
+ if not result:
+ continue
+ additions, edges = [], []
+ for site in result.get("nodes", []):
+ metadata = qt_metadata(site)
+ if metadata.get("kind") not in {"connect", "disconnect"} or metadata.get("status") == "resolved":
+ continue
+ qml_sender = _qml_endpoint(index, metadata, "sender")
+ qml_receiver = _qml_endpoint(index, metadata, "receiver")
+ signal = qml_sender if qml_sender.target_id else native.member(metadata.get("signal", {}), metadata.get("sender_type", ""), role="signal", owner_class=metadata.get("owner_class", ""))
+ receiver = qml_receiver if qml_receiver.target_id else native.member(metadata.get("receiver", {}), metadata.get("receiver_type", ""), role=metadata.get("receiver", {}).get("role") or None, owner_class=metadata.get("owner_class", ""))
+ if not qml_sender.target_id and not qml_receiver.target_id:
+ continue
+ if not signal.target_id or not receiver.target_id:
+ update_qt(site, status="unavailable", reason="cross_language_endpoint_unestablished")
+ continue
+ offered = metadata.get("signal", {}).get("parameter_types", []) if qml_sender.target_id else qt_metadata(native.nodes[signal.target_id]).get("parameter_types", [])
+ required = metadata.get("receiver", {}).get("parameter_types", []) if qml_receiver.target_id else qt_metadata(native.nodes[receiver.target_id]).get("parameter_types", [])
+ if len(required) > len(offered) or [value_type(v) for v in offered[:len(required)]] != [value_type(v) for v in required]:
+ update_qt(site, status="unavailable", reason="cross_language_signature_mismatch")
+ continue
+ direction = "qml_to_cpp" if qml_sender.target_id else "cpp_to_qml"
+ update_qt(site, status="resolved", reason="", signal_target_id=signal.target_id, receiver_target_id=receiver.target_id, bridge_direction=direction)
+ # Each occurrence owns its endpoint annotation. Revisiting earlier
+ # accumulated edges changes their operation/direction and recursively
+ # encodes raw literal transport; update_qt retains only semantic values.
+ site_nodes, site_edges = [], []
+ _role(site, signal.target_id, "signal", signal, site_nodes, site_edges)
+ _role(site, receiver.target_id, "receiver", receiver, site_nodes, site_edges)
+ for node in site_nodes:
+ update_qt(node, bridge_direction=direction)
+ for edge in site_edges:
+ values = qt_metadata(edge)
+ update_qt(edge, bridge_direction=direction)
+ if edge.get("relation") == "references":
+ edge["context"] = "qt_qml_" + metadata["kind"] + "_" + values.get("role", "")
+ # Upgrade only the same occurrence's partial native role
+ # edge. Leaving both contexts lets raw pair deduplication
+ # retain the older incomplete bridge instead of this proof.
+ old_context = "qt_" + metadata["kind"] + "_" + values.get("role", "")
+ for previous in result.get("edges", []):
+ if (previous.get("context") == old_context
+ and previous.get("relation") == "references"
+ and previous.get("source") == edge["source"]
+ and previous.get("target") == edge["target"]
+ and previous.get("source_file") == site["source_file"]):
+ previous["context"] = edge["context"]
+ additions.extend(site_nodes)
+ edges.extend(site_edges)
+ # The native pass may already own one endpoint of this fresh occurrence.
+ # Reuse that canonical role ID and annotate its source-owned dictionary;
+ # a duplicate filtered below must not leave the native placeholder stale.
+ owned = {node["id"]: node for node in result.get("nodes", [])}
+ for node in additions:
+ if node["id"] in owned:
+ update_qt(owned[node["id"]], bridge_direction=qt_metadata(node)["bridge_direction"])
+ known = set(owned)
+ fresh = [node for node in additions if node["id"] not in known]
+ result.setdefault("nodes", []).extend(fresh)
+ all_nodes.extend(fresh)
+ edge_keys = {(edge["source"], edge["target"], edge.get("context")) for edge in result.get("edges", [])}
+ owned_edges = {(edge["source"], edge["target"], edge.get("context")): edge
+ for edge in result.get("edges", [])}
+ for edge in edges:
+ previous = owned_edges.get((edge["source"], edge["target"], edge.get("context")))
+ if previous is not None:
+ update_qt(previous, bridge_direction=qt_metadata(edge)["bridge_direction"])
+ fresh_edges = [edge for edge in edges if (edge["source"], edge["target"], edge.get("context")) not in edge_keys]
+ result.setdefault("edges", []).extend(fresh_edges)
+ all_edges.extend(fresh_edges)
diff --git a/graphify/qt_qml_pipeline.py b/graphify/qt_qml_pipeline.py
new file mode 100644
index 0000000000..93685e337b
--- /dev/null
+++ b/graphify/qt_qml_pipeline.py
@@ -0,0 +1,106 @@
+"""Join accepted Qt/QML facts after canonical C++ identities are finalized."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.qml_resolution_types import source_path
+
+
+def _diagnostic_source(path: Path, root: Path) -> str:
+ """Return a bounded relative label without granting source or target authority."""
+ # Match the existing safety display's 160-character bound. Diagnostic
+ # fallback must not retry a failed identity operation without a guard, leak
+ # a foreign root, or truncate a label into a different apparent source.
+ try:
+ relative = source_path({"source_file": str(path.resolve())}, Path(root).resolve())
+ except (OSError, RuntimeError, ValueError):
+ relative = source_path({"source_file": str(path)}, Path(root))
+ if (relative is None or len(relative) > 160
+ or any(ord(char) < 32 or 127 <= ord(char) < 160 or char in "\u2028\u2029"
+ for char in relative)):
+ return ""
+ return relative
+
+
+def resolve_qt_qml(paths, per_file, all_nodes, all_edges, *, root,
+ context_nodes=(), context_edges=(), import_roots=None):
+ """Publish successful scratch joins; never annotate borrowed context facts."""
+ from graphify.extractors.qt_cpp_access import collect_qt_cpp_access
+ from graphify.extractors.qt_cpp_events import collect_qt_cpp_events
+ from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp
+ from graphify.qt_event_resolution import resolve_qt_events
+ from graphify.qt_qml_access_resolution import resolve_qt_qml_access
+ from graphify.qt_qml_bridge import build_qt_qml_bridge
+ from graphify.qt_project_index import QtProjectIndex
+ from graphify.qt_project_membership import resolve_project_memberships
+ from graphify.qt_source_containment import attach_qt_file_sites
+
+ fresh_ids = {node["id"] for node in all_nodes}
+ nodes = all_nodes + [node for node in context_nodes or () if node.get("id") not in fresh_ids]
+ edges = all_edges + list(context_edges or ())
+ initial_ids = {node["id"] for node in nodes}
+ initial_edges = {id(edge) for edge in edges}
+ results = {path: result for path, result in zip(paths, per_file) if result}
+ active_paths, active_results = list(results), list(results.values())
+
+ def collect(collector):
+ counts = [(len(result.get("nodes", [])), len(result.get("edges", []))) for result in active_results]
+ collector(active_paths, active_results, root=root, accepted_nodes=nodes, accepted_edges=edges)
+ known = {node["id"] for node in nodes}
+ for result, (n0, e0) in zip(active_results, counts):
+ for node in result.get("nodes", [])[n0:]:
+ if node["id"] not in known:
+ nodes.append(node)
+ known.add(node["id"])
+ edges.extend(result.get("edges", [])[e0:])
+
+ try:
+ collect(enrich_qt_cpp)
+ collect(collect_qt_cpp_events)
+ collect(collect_qt_cpp_access)
+ project_index = QtProjectIndex(nodes, edges, root=root, import_roots=import_roots)
+ # Packaging membership is source evidence independent of QML use/load.
+ # Only fresh declarations publish decisions over the accepted corpus.
+ membership_nodes, _ = resolve_project_memberships(
+ results, nodes, edges, root=root, project_index=project_index,
+ fresh_ast_ids=fresh_ids)
+ native_index = build_qt_qml_bridge(nodes, edges, root=root, project_index=project_index)
+ if any(node.get("metadata", {}).get("qml") for node in nodes):
+ from graphify.qml_relationships import resolve_qml_relationships
+ from graphify.qml_resolution import resolve_qml_project
+ resolve_qml_project(results, nodes, edges, root=root, import_roots=import_roots, native_index=native_index, project_index=project_index)
+ resolve_qml_relationships(results, nodes, edges, root=root, import_roots=import_roots, native_index=native_index, project_index=project_index)
+ resolve_qt_events(results, nodes, edges, root=root)
+ resolve_qt_qml_access(results, nodes, edges, root=root, project_index=project_index)
+ # Unresolved callable/class identity does not erase observed membership
+ # in a uniquely accepted source file; this grants no native target role.
+ edges.extend(attach_qt_file_sites(results, nodes, edges, root=root, fresh_ast_ids=fresh_ids))
+ for diagnostic in project_index.diagnostics:
+ for path, result in results.items():
+ if Path(path).resolve().relative_to(root).as_posix() == diagnostic["source_file"]:
+ result.setdefault("diagnostics", []).append(dict(diagnostic))
+ break
+ except Exception:
+ # Do not expose backend errors or let partial joins reach a graph writer.
+ for path, result in results.items():
+ path = Path(path)
+ if path.suffix.lower() not in {".qml", ".js", ".mjs", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".hxx", ".qmltypes", ".cmake", ".pro", ".pri", ".qrc"} and path.name not in {"qmldir", "CMakeLists.txt"}:
+ continue
+ # A join can fail because this same input no longer resolves. Its
+ # diagnostic label is display context only; unavailable context is
+ # explicit and cannot interrupt annotation of the remaining files.
+ relative = _diagnostic_source(path, root)
+ result.setdefault("qml_failures", []).append({"code": "QML_RESOLUTION_FAILED", "source_file": relative})
+ result.setdefault("diagnostics", []).append({"code": "QML_RESOLUTION_FAILED", "severity": "error",
+ "owner": "qt_qml_resolution", "source_file": relative, "message": "Qt/QML project join failed",
+ "recovery": "Correct the join failure and retry; prior graph is preserved."})
+ else:
+ # Derived membership decisions can replace borrowed same-ID sites.
+ # Publish fresh facts by identity, so removal of stale context cannot
+ # shift list offsets or leak unrelated borrowed nodes into this result.
+ membership_ids = {node["id"] for node in membership_nodes}
+ all_nodes.extend(node for node in nodes if node["id"] not in initial_ids
+ or (node["id"] in membership_ids and node["id"] not in fresh_ids))
+ all_edges.extend(edge for edge in edges if id(edge) not in initial_edges)
+
+ return [str(path) for path, result in results.items() if result.get("qml_failures") or result.get("qt_failures")]
diff --git a/graphify/qt_qml_projection.py b/graphify/qt_qml_projection.py
new file mode 100644
index 0000000000..631ac72249
--- /dev/null
+++ b/graphify/qt_qml_projection.py
@@ -0,0 +1,185 @@
+"""Validate versioned endpoint evidence for explicit Qt/QML language bridges."""
+from pathlib import Path
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_context_paths import valid_context_chain, valid_context_endpoint
+from graphify.qt_qml_callback_proof import valid_subscription_callback
+
+_CPP = {".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".hxx"}
+
+
+def is_typed_qt_qml_edge(source, target, edge):
+ """Select existing bridge producers independently of their relation/context/confidence.
+
+ Classification grants no authority: even malformed transport selected here
+ must pass the full consumer proof. Context-binding ownership/exposure remains
+ under its separate contract rather than being reclassified as member access.
+ """
+ src = Path(source.get("source_file") or "").suffix.lower()
+ tgt = Path(target.get("source_file") or "").suffix.lower()
+ if not (src == ".qml" and tgt in _CPP or src in _CPP and tgt == ".qml"):
+ return False
+ source_metadata, edge_metadata = source.get("metadata"), edge.get("metadata")
+ target_metadata = target.get("metadata")
+ source_qt = source_metadata.get("qt", {}) if isinstance(source_metadata, dict) else {}
+ target_qt = target_metadata.get("qt", {}) if isinstance(target_metadata, dict) else {}
+ edge_qt = edge_metadata.get("qt", {}) if isinstance(edge_metadata, dict) else {}
+ producer_kinds = {"context_access", "context_subscription", "qml_load", "qml_root", "qml_access", "event_endpoint"}
+ return (isinstance(edge_qt, dict) and (edge_qt.get("kind") == "qml_bridge" or "native_endpoint" in edge_qt)
+ or any(isinstance(value, dict) and value.get("kind") in producer_kinds for value in (source_qt, target_qt)))
+
+
+def _context_mechanism(source, source_qt, owner, proof, edge):
+ """A derived context access borrows its original read/call, never a new operation."""
+ if source_qt.get("kind") == "context_subscription":
+ return edge.get("relation") == "references" and edge.get("context") == "qt_context_subscription"
+ md = qml_metadata(owner)
+ expected = "calls" if md.get("kind") == "call" and proof.get("kind") == "function" else "uses"
+ return (md.get("contract_version") == 1 and md.get("kind") in {"read", "call"}
+ and md.get("status") == "unavailable" and not md.get("lexical_shadowed") and not md.get("script_file")
+ and owner.get("source_file") == source.get("source_file")
+ and owner.get("source_location") == source.get("source_location")
+ and md.get("span") == source_qt.get("span")
+ and edge.get("context") == "qt_context_member" and edge.get("relation") == expected)
+
+
+def _native_mechanism(md, proof, member, edge, target_id):
+ """Registered native APIs retain source expression roles and endpoint declaration roles."""
+ kind, role = md.get("kind"), proof.get("kind")
+ if (md.get("status") != "resolved" or kind == "handler" and (
+ md.get("disabled_reason") or md.get("attached_prefix")
+ or md.get("connections") and (not md.get("connection_supplied") or not md.get("connection_target")))):
+ return False
+ if role in {"module", "component"}:
+ expected = ("import_resolution", "imports", "qml_import_resolution") if role == "module" else (
+ "type_use", "uses", "qml_type_resolution")
+ return (kind, edge.get("relation"), edge.get("context")) == expected
+ roles = set(member.get("roles", []))
+ declared = "property" if member.get("kind") == "property" else "signal" if "signal" in roles else (
+ "function" if roles & {"invokable", "slot"} else "")
+ if (role != declared or md.get("resolved_target_id") != target_id or member.get("status") != "resolved"
+ or member.get("kind") == "member" and member.get("access") != "public"):
+ return False
+ expected = {"read": ("uses", "qml_binding_read"), "alias": ("references", "qml_alias_target"),
+ "handler": ("references", "qml_signal_subscription") if role == "signal" else None,
+ "call": ("uses", "qml_signal_emit") if role == "signal" else (
+ "calls", "qml_js_call") if role == "function" else None}.get(kind)
+ return expected == (edge.get("relation"), edge.get("context"))
+
+
+def _cpp_mechanism(source, md, target_md, edge, nodes, target_id):
+ """Reverse operations and connection endpoints have separate observed mechanisms."""
+ kind, operation, target_kind = md.get("kind"), md.get("operation"), target_md.get("kind")
+ if (md.get("conditional") or md.get("status") != "resolved"
+ or md.get("loader_supported") is False or md.get("reflection_supported") is False):
+ return False
+ if kind == "event_endpoint":
+ owner = nodes.get(md.get("owner_id"), {})
+ parent = qt_metadata(owner)
+ role = md.get("role")
+ return (role in {"signal", "receiver"} and parent.get("kind") in {"connect", "disconnect"}
+ and parent.get("status") == "resolved" and not parent.get("conditional")
+ and md.get("bridge_direction") == parent.get("bridge_direction")
+ == ("qml_to_cpp" if role == "signal" else "cpp_to_qml")
+ and parent.get(role + "_target_id") == target_id
+ and md.get("span") == parent.get("span")
+ and owner.get("source_file") == source.get("source_file")
+ and owner.get("source_location") == source.get("source_location")
+ and target_kind in ({"signal"} if role == "signal" else {"signal", "function"})
+ and edge.get("relation") == "references"
+ and edge.get("context") == "qt_qml_" + parent["kind"] + "_" + role)
+ if kind == "qml_load":
+ expected = ("uses", "qt_cpp_qml_load") if (target_kind in {"component", "file"}
+ and operation in {"load", "loadFromModule", "loadUrl", "setSource", "component_constructor", "engine_url_constructor"}) else None
+ elif kind == "qml_root":
+ expected = ("uses", "qt_cpp_qml_load") if (target_kind == "object"
+ and operation in {"rootObject", "rootObjects", "create", "createWithInitialProperties"}) else None
+ elif kind == "qml_access":
+ if operation == "findChild":
+ expected = ("uses", "qt_cpp_qml_find_child") if target_kind == "object" else None
+ elif operation == "invokeMethod":
+ expected = ("calls" if target_kind == "function" else "uses", "qt_cpp_qml_invoke") if target_kind in {"function", "signal"} else None
+ elif operation in {"setProperty", "write", "property", "read", "QQmlProperty"}:
+ expected = ("uses", "qt_cpp_qml_property_write" if operation in {"setProperty", "write"} else "qt_cpp_qml_property_read") if target_kind == "property" else None
+ else:
+ expected = None
+ else:
+ expected = None
+ return md.get("bridge_direction") == "cpp_to_qml" and expected == (edge.get("relation"), edge.get("context"))
+
+
+def allows_qt_qml_edge(source, target, edge, *, source_id, target_id, nodes):
+ """A context label cannot authorize a bridge to an unrelated declaration."""
+ if edge.get("confidence") != "INFERRED" or edge.get("source_file") != source.get("source_file"):
+ return False
+ src_ext = Path(source.get("source_file") or "").suffix.lower()
+ tgt_ext = Path(target.get("source_file") or "").suffix.lower()
+ try:
+ smd, tmd = qml_metadata(source), qml_metadata(target)
+ source_qt = qt_metadata(source)
+ # A retained endpoint ID cannot repair a changed source span or mechanism.
+ source_span = source_qt.get("span") if source_qt else smd.get("span")
+ if not source_span or qt_metadata(edge).get("span") != source_span:
+ return False
+ if src_ext == ".qml" and tgt_ext in _CPP and source_qt.get("kind") in {"context_access", "context_subscription"}:
+ proof = qt_metadata(edge).get("endpoint_proof", {})
+ binding_id, owner_id = source_qt.get("binding_id"), source_qt.get("owner_id")
+ if not isinstance(proof, dict) or binding_id not in nodes or owner_id not in nodes:
+ return False
+ binding = qt_metadata(nodes[binding_id])
+ component_id, class_id = binding.get("component_target_id"), binding.get("provider_target_id")
+ member_id = proof.get("member_fact_id")
+ if component_id not in nodes or class_id not in nodes or member_id not in nodes:
+ return False
+ component, owner, member = qml_metadata(nodes[component_id]), qml_metadata(nodes[owner_id]), qt_metadata(nodes[member_id])
+ subscription = source_qt.get("kind") == "context_subscription"
+ if subscription and (proof.get("kind") != "signal" or source_qt.get("handler_target_id") not in nodes):
+ return False
+ handler = nodes.get(source_qt.get("handler_target_id"), {}) if subscription else {}
+ if subscription and (handler.get("source_file") != source.get("source_file")
+ or qml_metadata(handler).get("kind") not in {"handler", "function", "js_function"}
+ or qml_metadata(handler).get("component_key") != component.get("component_key")
+ or not valid_subscription_callback(source, source_qt, nodes)):
+ return False
+ expected = member_id if member.get("kind") == "property" else member.get("generic_target_id")
+ return (source_qt.get("status") == "resolved" and binding.get("kind") == "context_binding"
+ and source_qt.get("bridge_direction") == qt_metadata(edge).get("bridge_direction") == "qml_to_cpp"
+ and binding.get("status") == "resolved" and source_qt.get("target_id") == target_id
+ and proof.get("canonical_target_id") == target_id and proof.get("provider_id") == binding_id
+ and valid_context_chain(binding, proof, nodes) and valid_context_endpoint(proof, nodes)
+ and member.get("class_id") == proof.get("class_id")
+ and expected == target_id and component.get("component_key") == owner.get("component_key")
+ and nodes[component_id].get("source_file") == source.get("source_file")
+ and _context_mechanism(source, source_qt, nodes[owner_id], proof, edge))
+ if smd.get("contract_version") == 1 and src_ext == ".qml" and tgt_ext in _CPP:
+ proof = qt_metadata(edge).get("native_endpoint", {})
+ if not isinstance(proof, dict) or proof.get("canonical_target_id") != target_id:
+ return False
+ provider_id, class_id = proof.get("provider_id"), proof.get("class_id")
+ if provider_id not in nodes or class_id not in nodes:
+ return False
+ provider = qt_metadata(nodes[provider_id])
+ if provider.get("kind") != "registration" or provider.get("class_id") != class_id:
+ return False
+ if proof.get("kind") in {"module", "component"}:
+ return target_id == provider_id and _native_mechanism(smd, proof, {}, edge, target_id)
+ member_id = proof.get("member_fact_id")
+ if member_id not in nodes:
+ return False
+ member = qt_metadata(nodes[member_id])
+ expected = member_id if member.get("kind") == "property" else member.get("generic_target_id")
+ return (member.get("class_id") == class_id and expected == target_id
+ and member.get("kind") in {"property", "member"}
+ and _native_mechanism(smd, proof, member, edge, target_id))
+ if src_ext in _CPP and tgt_ext == ".qml" and tmd.get("contract_version") == 1:
+ proof = qt_metadata(edge)
+ return (source_qt.get("kind") in {"qml_load", "qml_root", "qml_access", "event_endpoint", "context_exposure", "initial_properties"}
+ and source_qt.get("target_id") == target_id and proof.get("target_id") == target_id
+ and proof.get("bridge_direction") in {"cpp_to_qml", "qml_to_cpp"}
+ and proof.get("bridge_direction") == source_qt.get("bridge_direction")
+ and source_qt.get("status") == "resolved"
+ and _cpp_mechanism(source, source_qt, tmd, edge, nodes, target_id))
+ except (ValueError, TypeError):
+ return False
+ return False
diff --git a/graphify/qt_qml_search.py b/graphify/qt_qml_search.py
new file mode 100644
index 0000000000..8394accfd4
--- /dev/null
+++ b/graphify/qt_qml_search.py
@@ -0,0 +1,98 @@
+"""Bounded semantic search views for Qt/QML facts; identities stay opaque."""
+from __future__ import annotations
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+
+SEARCH_NAMESPACE = "graphify_qt_qml"
+_FIELDS = {"kind", "raw_name", "raw_type", "type_name", "class_name", "signature", "uri",
+ "qualifier", "import_kind", "mechanism", "status", "reason", "metadata_format", "target_name",
+ "declared_type", "callable_form", "operation", "bridge_direction"}
+_FLAGS = {"singleton", "creatable", "anonymous", "internal", "required", "readonly", "source_q_object"}
+_LISTS = {"roles", "flags"}
+MAX_TEXT = 384
+MAX_ROLES = 16
+
+
+def _text(value):
+ return value if isinstance(value, str) and len(value) <= MAX_TEXT else None
+
+
+def _view(values):
+ if values.get("contract_version") != 1:
+ return {}
+ result: dict[str, object] = {key: text for key in sorted(_FIELDS) if (text := _text(values.get(key))) is not None and text}
+ for key in sorted(_LISTS):
+ values_list = values.get(key)
+ if isinstance(values_list, list) and len(values_list) <= MAX_ROLES:
+ result[key] = [value for value in values_list if _text(value)]
+ result.update({key: values[key] for key in sorted(_FLAGS) if isinstance(values.get(key), bool)})
+ if values.get("kind") in {"import", "module_import", "script_import"}:
+ value = _text(values.get("value"))
+ if value:
+ result["import_value"] = value
+ major, minor = values.get("major"), values.get("minor")
+ if type(major) is int and 0 <= major <= 1_000_000:
+ result["major"] = major
+ if type(minor) is int and 0 <= minor <= 1_000_000:
+ result["minor"] = minor
+ return result
+
+
+def _decode_view(name, raw, decode):
+ """Decode only bounded search fields, never copy unrelated payload trees."""
+ selected = {key: value for key in _FIELDS | _FLAGS | {"major", "minor", "value"}
+ if (value := raw.get(key)) is not None and
+ (isinstance(value, (bool, int)) or _text(value) is not None)}
+ for key in _LISTS:
+ values = raw.get(key)
+ if isinstance(values, list) and len(values) <= MAX_ROLES and all(_text(value) is not None for value in values):
+ selected[key] = list(values)
+ literals = raw.get("raw_values", {})
+ if not isinstance(literals, dict) or len(literals) > 100:
+ return {}
+ selected["contract_version"] = 1
+ encoded = {}
+ for key, value in literals.items():
+ if not isinstance(key, str) or len(key) > 128:
+ return {}
+ if key not in selected and not any(key.startswith(name + "/") and name in selected for name in _LISTS):
+ continue
+ # Validate before the Qt decoder copies the packet through JSON. A
+ # nested malformed value must never reach that recursive copy.
+ if not isinstance(value, str) or len(value) > 512:
+ return {}
+ encoded[key] = value
+ selected["raw_values"] = encoded
+ return _view(decode({"metadata": {name: selected}}))
+
+
+def search_attributes(node):
+ """Return a new attribute view without editing source nodes or old attributes.
+
+ Only public semantic fields are indexed. Source IDs, opaque scope keys,
+ transport copies, expression bodies and supplied literal data are excluded.
+ Existing use of the reserved namespace wins instead of being overwritten.
+ Malformed/future metadata cannot make ordinary graph search fail.
+ """
+ attrs = node.get("attributes")
+ result = dict(attrs) if isinstance(attrs, dict) else {}
+ if SEARCH_NAMESPACE in result:
+ return result
+ metadata = node.get("metadata")
+ if not isinstance(metadata, dict):
+ return result
+ views = {}
+ for name, decode in (("qml", qml_metadata), ("qt", qt_metadata)):
+ raw = metadata.get(name)
+ if not isinstance(raw, dict) or type(raw.get("contract_version")) is not int or raw.get("contract_version") != 1:
+ continue
+ try:
+ values = _decode_view(name, raw, decode)
+ except (ValueError, TypeError, UnicodeError):
+ continue
+ if values:
+ views[name] = values
+ if views:
+ result[SEARCH_NAMESPACE] = views
+ return result
diff --git a/graphify/qt_relationship_views.py b/graphify/qt_relationship_views.py
new file mode 100644
index 0000000000..30f2c4da00
--- /dev/null
+++ b/graphify/qt_relationship_views.py
@@ -0,0 +1,67 @@
+"""HTML semantic relationship views keep source dependencies distinct from calls."""
+from __future__ import annotations
+
+from html import escape
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+
+_LABELS = {
+ "qt_signal_emit": "signal emission", "qt_connect_signal": "connection signal", "qt_connect_receiver": "connection receiver",
+ "qt_disconnect_signal": "disconnect signal", "qt_disconnect_receiver": "disconnect receiver", "qt_disconnect": "disconnect handle",
+ "qt_qml_connect_signal": "QML connection signal", "qt_qml_connect_receiver": "QML connection receiver",
+ "qt_qml_disconnect_signal": "QML disconnect signal", "qt_qml_disconnect_receiver": "QML disconnect receiver",
+ "qml_signal_subscription": "signal subscription", "qml_signal_emit": "QML signal emission",
+ "qml_binding_read": "binding read", "qml_alias_target": "alias target", "qml_js_call": "JavaScript call", "qml_script_call": "imported script call",
+ "qml_import_resolution": "QML import", "qml_type_resolution": "QML type", "qml_type_base": "QML base type",
+ "qt_cpp_qml_load": "component or object access", "qt_cpp_qml_find_child": "objectName lookup",
+ "qt_cpp_qml_property_read": "QML property read", "qt_cpp_qml_property_write": "QML property write", "qt_cpp_qml_invoke": "QML method invocation",
+ "qt_context_exposure": "context provider", "qt_initial_property": "initial property provider", "qt_context_member": "QML context member",
+}
+_EVENTS = {key for key in _LABELS if "connect" in key or "signal" in key}
+_KINDS = {"type_use", "import_resolution", "read", "call", "handler", "alias", "qml_load", "qml_root", "qml_access", "context_exposure", "initial_properties", "connect", "disconnect", "emission"}
+
+
+def relationship_label(edge):
+ """A free-form context string alone is not versioned source evidence."""
+ if edge.get("context") not in _LABELS:
+ return None
+ try:
+ if qml_metadata(edge).get("contract_version") != 1 and qt_metadata(edge).get("contract_version") != 1:
+ return None
+ except (ValueError, TypeError):
+ return None
+ return _LABELS[edge["context"]]
+
+
+def is_event_relationship(edge):
+ return edge.get("context") in _EVENTS and relationship_label(edge) is not None
+
+
+def render_relationship_table(nodes, edges, lang="en"):
+ """Render accepted links and explicit unresolved source facts as HTML text."""
+ del lang
+ by_id = {node["id"]: node for node in nodes}
+ rows = []
+ for edge in edges:
+ label = relationship_label(edge)
+ source, target = by_id.get(edge.get("source")), by_id.get(edge.get("target"))
+ if not label or not source or not target:
+ continue
+ location = str(edge.get("source_file") or "") + ":" + str(edge.get("source_location") or "")
+ rows.append('| '
+ + escape(source.get("label") or source["id"]) + ' | ' + escape(label) + ' | ' + escape(target.get("label") or target["id"])
+ + ' | ' + escape(location) + ' | ' + escape(str(edge.get("confidence") or "")) + ' |
')
+ for node in nodes:
+ try:
+ metadata = qml_metadata(node) or qt_metadata(node)
+ except (ValueError, TypeError):
+ continue
+ if metadata.get("kind") not in _KINDS or metadata.get("status") not in {"ambiguous", "dynamic", "unsupported", "unavailable"}:
+ continue
+ rows.append('| ' + escape(node.get("label") or node["id"])
+ + ' | ' + escape(metadata["status"]) + ' | ' + escape(str(metadata.get("reason") or "target unestablished"))
+ + ' | ' + escape(str(node.get("source_file") or "") + ':' + str(node.get("source_location") or "")) + ' | source fact |
')
+ if not rows:
+ return ""
+ return 'Qt/QML source relationships
Connections and subscriptions describe source facts; runtime delivery is unverified.
| Source | Relationship / status | Target / reason | Location | Evidence |
' + ''.join(rows) + '
'
diff --git a/graphify/qt_resource_index.py b/graphify/qt_resource_index.py
new file mode 100644
index 0000000000..051075a06c
--- /dev/null
+++ b/graphify/qt_resource_index.py
@@ -0,0 +1,107 @@
+"""Immutable accepted-fact URL lookups; no runtime URL base or source expansion."""
+from __future__ import annotations
+
+import posixpath
+from pathlib import Path
+from urllib.parse import unquote, urljoin, urlsplit
+
+from graphify.extractors.qml_resources import resource_url
+from graphify.qml_resolution_types import Resolution, answer, qml_metadata, source_path
+
+
+def normalize_url(literal_url: str, base_url: str | None = None) -> str | Resolution:
+ """Normalize only supported explicit URL syntax, including the runtime base."""
+ if not isinstance(literal_url, str) or not literal_url or len(literal_url) > 384:
+ return Resolution("unsupported", reason="invalid_url_literal")
+ if literal_url.startswith(":/"):
+ literal_url = "qrc" + literal_url
+ try:
+ parsed = urlsplit(literal_url)
+ path = unquote(parsed.path, errors="strict")
+ except (ValueError, UnicodeError):
+ return Resolution("unsupported", reason="invalid_url_encoding_or_syntax")
+ if parsed.query or parsed.fragment or parsed.netloc:
+ return Resolution("unsupported", reason="url_query_fragment_or_authority")
+ if "\x00" in path or "\\" in path or any(part in {".", ".."} for part in path.split("/")):
+ return Resolution("unavailable", reason="url_path_escape")
+ if parsed.scheme == "qrc":
+ logical = resource_url("/", path.lstrip("/")) if path.startswith("/") else None
+ return logical or Resolution("unsupported", reason="invalid_resource_url")
+ if parsed.scheme == "file":
+ return "file:" + parsed.path
+ if parsed.scheme:
+ return Resolution("unsupported", reason="unsupported_url_scheme")
+ if not base_url:
+ return Resolution("unavailable", reason="runtime_url_base_unavailable")
+ base = normalize_url(base_url)
+ if isinstance(base, Resolution):
+ return base
+ parsed_base = urlsplit(base)
+ if parsed_base.scheme == "qrc":
+ combined = "qrc:" + posixpath.join(posixpath.dirname(parsed_base.path), parsed.path)
+ elif parsed_base.scheme == "file":
+ combined = urljoin(base, literal_url)
+ else:
+ return Resolution("unsupported", reason="unsupported_url_base")
+ return normalize_url(combined)
+
+
+class QtResourceIndex:
+ def __init__(self, nodes, *, root: Path):
+ self.root = Path(root).resolve()
+ self.nodes, self.by_file, self.components, self.aliases = {}, {}, {}, {}
+ for node in nodes:
+ path = source_path(node, self.root)
+ if path is None or not node.get("id"):
+ continue
+ nid, md = node["id"], qml_metadata(node)
+ self.nodes[nid] = node
+ self.by_file.setdefault(path, []).append(nid)
+ if md.get("kind") == "component":
+ self.components.setdefault(path, []).append(nid)
+ elif md.get("kind") == "resource_alias":
+ self.aliases.setdefault(md.get("logical_url"), []).append(nid)
+
+ def _file(self, path: str, *, evidence=()) -> Resolution:
+ ids = self.components.get(path)
+ if ids is None:
+ ids = [nid for nid in self.by_file.get(path, []) if self.nodes[nid].get("type") == "file"]
+ return answer(ids, reason="resource_target_outside_corpus", evidence=evidence)
+
+ def _resource(self, literal: str) -> Resolution:
+ ids = self.aliases.get(literal, [])
+ if not ids:
+ return Resolution("unavailable", reason="resource_alias_unavailable")
+ if any(qml_metadata(self.nodes[nid]).get("locale") for nid in ids):
+ return Resolution("unsupported", reason="resource_locale_dependent", evidence=tuple(ids[:50]))
+ results = [self._file(qml_metadata(self.nodes[nid]).get("target_path", ""), evidence=(nid,))
+ for nid in ids]
+ if any(result.target_id is None for result in results):
+ return Resolution("unavailable", reason="resource_target_outside_corpus", evidence=tuple(ids[:50]))
+ # Duplicate mappings remain ambiguous even when their targets coincide.
+ if len(ids) != 1:
+ return Resolution("ambiguous", reason="duplicate_resource_alias",
+ candidates=tuple(sorted({result.target_id for result in results
+ if result.target_id is not None})[:50]),
+ evidence=tuple(ids[:50]))
+ return results[0]
+
+ def resolve_url(self, literal_url: str, base_url: str | None = None) -> Resolution:
+ """Resolve explicit qrc/file URLs or relative URLs with an explicit URL base."""
+ normalized = normalize_url(literal_url, base_url)
+ if isinstance(normalized, Resolution):
+ return normalized
+ parsed = urlsplit(normalized)
+ path = unquote(parsed.path)
+ if parsed.scheme == "qrc":
+ return self._resource(normalized)
+ if parsed.scheme == "file":
+ local = Path(path[1:] if len(path) > 3 and path[0] == "/" and path[2] == ":" else path)
+ if not local.is_absolute():
+ return Resolution("unsupported", reason="file_url_not_absolute")
+ try:
+ accepted = local.resolve().relative_to(self.root).as_posix()
+ except ValueError:
+ return Resolution("unavailable", reason="file_url_outside_root")
+ return self._file(accepted)
+ return Resolution("unsupported", reason="unsupported_url_scheme")
diff --git a/graphify/qt_source_containment.py b/graphify/qt_source_containment.py
new file mode 100644
index 0000000000..ffd51c53d2
--- /dev/null
+++ b/graphify/qt_source_containment.py
@@ -0,0 +1,49 @@
+"""Connect observed Qt sites to accepted source files without inventing owners."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata
+from graphify.qml_resolution_types import source_path
+from graphify.qt_source_file_role import generic_file_role
+
+
+def attach_qt_file_sites(results, nodes, edges, *, root, fresh_ast_ids=()):
+ """Use existing canonical file IDs; borrowed semantic metadata stays unchanged.
+
+ The generic producer's file role is its AST origin, code type, L1 location,
+ exact filename label and absence of callable/semantic roles. Bounded C++ file
+ transport is accepted without granting constructor/type authority. Containment
+ is independent of an unavailable callable, class or runtime target. Duplicate
+ or foreign file evidence cannot authorize a guessed container.
+ """
+ # Fresh canonical AST nodes precede the facade's final provenance tagging.
+ # Borrowed context needs its persisted marker; absence alone is no authority.
+ fresh_ast_ids = set(fresh_ast_ids)
+ files = {}
+ for node in nodes:
+ path = source_path(node, root)
+ if generic_file_role(node, path, fresh_ast_ids):
+ files.setdefault(path, []).append(node["id"])
+ existing = {(edge.get("source"), edge.get("target"), edge.get("context")) for edge in edges}
+ added = []
+ for result in results.values():
+ for site in result.get("nodes", []):
+ metadata = qt_metadata(site)
+ if not metadata or metadata.get("owner_id"):
+ continue
+ owners = set(files.get(source_path(site, root), []))
+ if len(owners) != 1:
+ continue
+ owner = next(iter(owners))
+ key = (owner, site["id"], "qt_source_file")
+ if key in existing:
+ continue
+ # Preserve the occurrence's original bytes and endpoint direction;
+ # owner_id remains unavailable rather than being changed to a file.
+ edge = {"source": owner, "target": site["id"], "_src": owner, "_tgt": site["id"],
+ "relation": "contains", "context": "qt_source_file", "confidence": "EXTRACTED",
+ "source_file": site["source_file"], "source_location": site["source_location"],
+ "metadata": {"qt": encode_qt({"span": metadata.get("span", {}), "target_id": site["id"]})}}
+ result.setdefault("edges", []).append(edge)
+ added.append(edge)
+ existing.add(key)
+ return added
diff --git a/graphify/qt_source_file_role.py b/graphify/qt_source_file_role.py
new file mode 100644
index 0000000000..817f91ae8c
--- /dev/null
+++ b/graphify/qt_source_file_role.py
@@ -0,0 +1,32 @@
+"""Accepted generic file roles shared by Qt membership and source containment."""
+from __future__ import annotations
+
+from pathlib import PurePosixPath
+
+from graphify.extractors.cpp_constructor_type_authority import valid_source_transport
+
+
+def generic_file_role(node, path, fresh_ast_ids=()):
+ """Transport augments source proof without becoming callable/type authority.
+
+ C++ file records now carry bounded constructor include/shadow evidence. Only
+ that exact producer contract is accepted alongside the legacy empty record;
+ unrelated metadata cannot turn a semantic node into a file. The caller owns
+ source-path normalization and uniqueness; this predicate never reads source.
+ """
+ origin = node.get("_origin")
+ if (not path or not (origin == "ast" or origin is None and node.get("id") in fresh_ast_ids)
+ or node.get("file_type") != "code" or node.get("source_location") != "L1"
+ or node.get("label") != PurePosixPath(path).name
+ or node.get("type") not in {None, "file"}
+ or node.get("_callable") or node.get("_callable_class")):
+ return False
+ metadata = node.get("metadata")
+ if metadata is None or metadata == {}:
+ return True
+ if not isinstance(metadata, dict) or set(metadata) != {"cpp_constructor_types"}:
+ return False
+ record = metadata["cpp_constructor_types"]
+ return (isinstance(record, dict)
+ and set(record) == {"contract_version", "complete", "source_size", "includes", "shadows"}
+ and valid_source_transport(record))
diff --git a/graphify/report.py b/graphify/report.py
index 168197d54e..d3493506c6 100644
--- a/graphify/report.py
+++ b/graphify/report.py
@@ -205,6 +205,11 @@ def _real_count(nodes) -> int:
f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output",
]
+ # Accepted source facts expose static resolution gaps independently of the
+ # edge-confidence summary; a valid graph cannot certify Qt runtime behavior.
+ from graphify.qt_coverage import coverage_lines
+ lines.extend(coverage_lines(G))
+
if built_at_commit:
lines += [
"",
diff --git a/graphify/serve.py b/graphify/serve.py
index c97da5ed0b..80c6be8a4d 100644
--- a/graphify/serve.py
+++ b/graphify/serve.py
@@ -341,7 +341,8 @@ def _flatten_attr_text(val, parts: list[str]) -> None:
def _node_attributes_text(data: dict) -> tuple[str, str]:
"""The node's `attributes` key-value pairs normalized and tokenized for search."""
- attrs = data.get("attributes")
+ from graphify.qt_qml_search import search_attributes
+ attrs = search_attributes(data)
if not isinstance(attrs, dict) or not attrs:
return "", ""
parts: list[str] = []
@@ -749,6 +750,9 @@ def _pick_scored_endpoint(G: nx.Graph, scored: list[tuple[float, str]], query: s
`scored` must be non-empty (both callers return early on no match).
"""
+ # A public node ID is an explicit identity request, not a fuzzy label query.
+ if query in G:
+ return query
qtokens = set(_search_tokens(query))
if not qtokens:
return scored[0][1]
@@ -1173,6 +1177,7 @@ def _adj(n):
f"{attrs_suffix}]"
)
lines.append(line)
+ render_edges = []
for u, v in edges:
if u in nodes and v in nodes:
raw = G[u][v]
@@ -1191,23 +1196,30 @@ def _adj(n):
# would KeyError on an unknown id (#2080 review).
if {src, tgt} != {u, v}:
src, tgt = u, v
- context = d.get("context")
- context_suffix = f" context={sanitize_label(str(context))}" if context else ""
- # The relation SITE (call/import/reference line in the source's
- # file), not a def line — so "who calls X" cites a clickable call
- # location, not the caller's def (#BUG1).
- _loc = str(d.get("source_location") or "")
- at_suffix = (
- f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(_loc)}"
- if _loc else ""
- )
- line = (
- f"EDGE {sanitize_label(G.nodes[src].get('label', src))} "
- f"--{sanitize_label(str(d.get('relation', '')))} "
- f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> "
- f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}"
- )
- lines.append(line)
+ render_edges.append((src, tgt, d))
+ # Traversal completion walks a set of visited nodes. Render source facts in
+ # stable semantic order so CLI and an independent MCP process agree.
+ render_edges.sort(key=lambda item: tuple(str(value or "") for value in (
+ item[0], item[1], *(item[2].get(key) for key in (
+ "relation", "context", "confidence", "source_file", "source_location")))))
+ for src, tgt, d in render_edges:
+ context = d.get("context")
+ context_suffix = f" context={sanitize_label(str(context))}" if context else ""
+ # The relation SITE (call/import/reference line in the source's
+ # file), not a def line — so "who calls X" cites a clickable call
+ # location, not the caller's def (#BUG1).
+ _loc = str(d.get("source_location") or "")
+ at_suffix = (
+ f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(_loc)}"
+ if _loc else ""
+ )
+ line = (
+ f"EDGE {sanitize_label(G.nodes[src].get('label', src))} "
+ f"--{sanitize_label(str(d.get('relation', '')))} "
+ f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> "
+ f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}"
+ )
+ lines.append(line)
output = "\n".join(lines)
if len(output) > char_budget:
cut_at = output[:char_budget].rfind("\n")
@@ -1815,6 +1827,7 @@ def _shortest_path_text(G: nx.Graph, arguments: dict) -> str:
if hops > max_hops:
return f"Path exceeds max_hops={max_hops} ({hops} hops found)."
segments = []
+ path_edges = []
for i in range(len(path_nodes) - 1):
u, v = path_nodes[i], path_nodes[i + 1]
# Report the actual stored relation(s), never a fabricated `calls`;
@@ -1828,6 +1841,7 @@ def _shortest_path_text(G: nx.Graph, arguments: dict) -> str:
for d in edge_datas(G, a, b):
(fwd if d.get("_src", a) == u else bwd).append(d)
datas = fwd or bwd
+ path_edges.extend(datas)
forward = bool(fwd)
rels = sorted({d.get("relation") for d in datas if d.get("relation")})
rel = "/".join(rels) if rels else "related"
@@ -1840,7 +1854,9 @@ def _shortest_path_text(G: nx.Graph, arguments: dict) -> str:
else:
segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}")
prefix = ("\n".join(warnings) + "\n") if warnings else ""
- return prefix + f"Shortest path ({hops} hops):\n " + " ".join(segments)
+ from graphify.path_provenance import path_provenance
+ sources = path_provenance([G.nodes[node_id] for node_id in path_nodes], path_edges)
+ return prefix + f"Shortest path ({hops} hops):\n " + " ".join(segments) + sources
def _filter_blank_stdin() -> None:
diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md
index 3f7fade819..87d10b0802 100644
--- a/graphify/skill-agents.md
+++ b/graphify/skill-agents.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md
index 6e459945af..f873f01a60 100644
--- a/graphify/skill-aider.md
+++ b/graphify/skill-aider.md
@@ -195,10 +195,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -208,7 +216,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files)
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
@@ -766,6 +779,10 @@ The graph is the map. Your job after the pipeline is to be the guide.
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md
index 3f7fade819..87d10b0802 100644
--- a/graphify/skill-amp.md
+++ b/graphify/skill-amp.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md
index 2eca2a295f..1f4d24162f 100644
--- a/graphify/skill-claw.md
+++ b/graphify/skill-claw.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md
index a126527fd7..d38dec09e9 100644
--- a/graphify/skill-codex.md
+++ b/graphify/skill-codex.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md
index 2eca2a295f..1f4d24162f 100644
--- a/graphify/skill-copilot.md
+++ b/graphify/skill-copilot.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-devin.md b/graphify/skill-devin.md
index 9bd1f0dc73..ac25fa6b6e 100644
--- a/graphify/skill-devin.md
+++ b/graphify/skill-devin.md
@@ -208,10 +208,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -221,7 +229,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files)
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
@@ -905,6 +918,10 @@ fi
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md
index b1eeb4925a..df8b4bb67d 100644
--- a/graphify/skill-droid.md
+++ b/graphify/skill-droid.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md
index 387563de43..cb4c434985 100644
--- a/graphify/skill-kilo.md
+++ b/graphify/skill-kilo.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md
index 2eca2a295f..1f4d24162f 100644
--- a/graphify/skill-kiro.md
+++ b/graphify/skill-kiro.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md
index 144378e65a..03baadef09 100644
--- a/graphify/skill-opencode.md
+++ b/graphify/skill-opencode.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md
index 2eca2a295f..1f4d24162f 100644
--- a/graphify/skill-pi.md
+++ b/graphify/skill-pi.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md
index 36706e3d7c..51273ca71c 100644
--- a/graphify/skill-trae.md
+++ b/graphify/skill-trae.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md
index 6191a4ca6d..00a2ba94cc 100644
--- a/graphify/skill-vscode.md
+++ b/graphify/skill-vscode.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md
index 456244a246..64bac5060c 100644
--- a/graphify/skill-windows.md
+++ b/graphify/skill-windows.md
@@ -205,10 +205,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```powershell
@'
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -218,7 +226,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
print(f'AST: {len(result["nodes"])} nodes, {len(result["edges"])} edges')
else:
diff --git a/graphify/skill.md b/graphify/skill.md
index 2eca2a295f..1f4d24162f 100644
--- a/graphify/skill.md
+++ b/graphify/skill.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/graphify/skills/agents/references/extraction-spec.md b/graphify/skills/agents/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/agents/references/extraction-spec.md
+++ b/graphify/skills/agents/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/agents/references/update.md b/graphify/skills/agents/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/agents/references/update.md
+++ b/graphify/skills/agents/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/amp/references/extraction-spec.md b/graphify/skills/amp/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/amp/references/extraction-spec.md
+++ b/graphify/skills/amp/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/amp/references/update.md b/graphify/skills/amp/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/amp/references/update.md
+++ b/graphify/skills/amp/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/claude/references/extraction-spec.md b/graphify/skills/claude/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/claude/references/extraction-spec.md
+++ b/graphify/skills/claude/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/claude/references/update.md b/graphify/skills/claude/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/claude/references/update.md
+++ b/graphify/skills/claude/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/claw/references/extraction-spec.md b/graphify/skills/claw/references/extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/graphify/skills/claw/references/extraction-spec.md
+++ b/graphify/skills/claw/references/extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/claw/references/update.md b/graphify/skills/claw/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/claw/references/update.md
+++ b/graphify/skills/claw/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/codex/references/extraction-spec.md b/graphify/skills/codex/references/extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/graphify/skills/codex/references/extraction-spec.md
+++ b/graphify/skills/codex/references/extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/codex/references/update.md b/graphify/skills/codex/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/codex/references/update.md
+++ b/graphify/skills/codex/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/copilot/references/extraction-spec.md b/graphify/skills/copilot/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/copilot/references/extraction-spec.md
+++ b/graphify/skills/copilot/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/copilot/references/update.md b/graphify/skills/copilot/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/copilot/references/update.md
+++ b/graphify/skills/copilot/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/droid/references/extraction-spec.md b/graphify/skills/droid/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/droid/references/extraction-spec.md
+++ b/graphify/skills/droid/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/droid/references/update.md b/graphify/skills/droid/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/droid/references/update.md
+++ b/graphify/skills/droid/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/kilo/references/extraction-spec.md b/graphify/skills/kilo/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/kilo/references/extraction-spec.md
+++ b/graphify/skills/kilo/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/kilo/references/update.md b/graphify/skills/kilo/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/kilo/references/update.md
+++ b/graphify/skills/kilo/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/kiro/references/extraction-spec.md b/graphify/skills/kiro/references/extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/graphify/skills/kiro/references/extraction-spec.md
+++ b/graphify/skills/kiro/references/extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/kiro/references/update.md b/graphify/skills/kiro/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/kiro/references/update.md
+++ b/graphify/skills/kiro/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/opencode/references/extraction-spec.md b/graphify/skills/opencode/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/opencode/references/extraction-spec.md
+++ b/graphify/skills/opencode/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/opencode/references/update.md b/graphify/skills/opencode/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/opencode/references/update.md
+++ b/graphify/skills/opencode/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/pi/references/extraction-spec.md b/graphify/skills/pi/references/extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/graphify/skills/pi/references/extraction-spec.md
+++ b/graphify/skills/pi/references/extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/pi/references/update.md b/graphify/skills/pi/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/pi/references/update.md
+++ b/graphify/skills/pi/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/trae/references/extraction-spec.md b/graphify/skills/trae/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/trae/references/extraction-spec.md
+++ b/graphify/skills/trae/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/trae/references/update.md b/graphify/skills/trae/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/trae/references/update.md
+++ b/graphify/skills/trae/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/vscode/references/extraction-spec.md b/graphify/skills/vscode/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/vscode/references/extraction-spec.md
+++ b/graphify/skills/vscode/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/vscode/references/update.md b/graphify/skills/vscode/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/vscode/references/update.md
+++ b/graphify/skills/vscode/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/skills/windows/references/extraction-spec.md b/graphify/skills/windows/references/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/graphify/skills/windows/references/extraction-spec.md
+++ b/graphify/skills/windows/references/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/graphify/skills/windows/references/update.md b/graphify/skills/windows/references/update.md
index 3632fd4126..a917e2224b 100644
--- a/graphify/skills/windows/references/update.md
+++ b/graphify/skills/windows/references/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/graphify/source_identity.py b/graphify/source_identity.py
new file mode 100644
index 0000000000..8586f6c0c4
--- /dev/null
+++ b/graphify/source_identity.py
@@ -0,0 +1,125 @@
+"""Stateless source spelling, walked naming, and shared physical ID ownership.
+
+Native input spelling is admitted before extraction. After physical containment,
+walked aliases retain their lexical owner, and shared resolved forms receive an
+explicit owner without relying on batch order. The caller owns admission scope,
+the per-run realpath cache, source reads, endpoint creation, and publication.
+"""
+from __future__ import annotations
+
+from collections.abc import Callable
+import ctypes
+import json
+import os
+from pathlib import Path
+import stat
+
+
+NATIVE_PATH_CAPACITY = 32768
+SOURCE_CONTEXT_LIMIT = 160
+
+
+def _source_context(path: Path, root: Path | None) -> str:
+ """Use only lexical context; failed admission cannot authorize another lookup."""
+ if root is None:
+ return ""
+ try:
+ relative = Path(os.path.abspath(path)).relative_to(Path(os.path.abspath(root))).as_posix()
+ except (OSError, RuntimeError, ValueError):
+ return ""
+ if len(relative) > SOURCE_CONTEXT_LIMIT or any(
+ ord(char) < 32 or 127 <= ord(char) < 160 or char in "\u2028\u2029"
+ for char in relative):
+ return ""
+ return relative
+
+
+def _native_long_name(path: Path) -> str:
+ """Expand NTFS entry spellings without resolving lexical junction owners."""
+ api = ctypes.windll.kernel32.GetLongPathNameW
+ api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
+ api.restype = ctypes.c_uint32
+ buffer = ctypes.create_unicode_buffer(NATIVE_PATH_CAPACITY)
+ length = api(str(path), buffer, len(buffer))
+ value = buffer.value
+ # Win32 reports UTF-16 code units; a supplementary character occupies two.
+ units = len(value.encode("utf-16-le", errors="surrogatepass")) // 2
+ if not length or length >= len(buffer) or not value or units != length:
+ raise ValueError("invalid native spelling result")
+ return value
+
+
+def normalize_input_source(path: Path, root: Path | None = None) -> Path:
+ """Admit one native entry spelling before dispatch, facts, or cache ownership.
+
+Missing/non-file inputs retain their existing downstream handling. Existing
+Windows files must expand to the same physical target; the lexical returned
+name preserves separate discovered symlink/junction owners. No content is read.
+"""
+ if os.name != "nt":
+ return path
+ try:
+ try:
+ mode = path.stat().st_mode
+ except (FileNotFoundError, NotADirectoryError):
+ return path
+ if not stat.S_ISREG(mode):
+ return path
+ physical = path.resolve(strict=True)
+ expanded = Path(_native_long_name(path))
+ if expanded.resolve(strict=True) != physical or not expanded.samefile(path):
+ raise ValueError("native spelling changed physical input")
+ return expanded
+ except (OSError, RuntimeError, ValueError, AttributeError, ctypes.ArgumentError):
+ source = json.dumps(_source_context(path, root), ensure_ascii=True)
+ raise ValueError(f"SOURCE_INPUT_IDENTITY_FAILED: source={source}; native source "
+ "spelling unavailable; repair filesystem access and retry") from None
+
+
+def resolved_source_owners(paths: list[Path], root: Path, *,
+ walked_relative: Callable[[Path, Path], Path],
+ realpath: Callable[[str, str], Path], cwd: str) -> dict[Path, Path]:
+ """Choose shared physical ID ownership independently of input ordering.
+
+Every walked source keeps its own primary identity. A supplied physical owner
+owns its resolved spelling; one alias preserves the historical unique fallback.
+Multiple aliases without that owner cannot authorize choosing one arbitrarily:
+their shared target retains its portable physical ID without minting a node.
+"""
+ claims: dict[Path, set[Path]] = {}
+ for path in paths:
+ physical = realpath(str(path), cwd)
+ try:
+ relative = walked_relative(path, physical)
+ except ValueError:
+ continue
+ claims.setdefault(physical, set()).add(relative)
+ owners = {}
+ for physical, relatives in claims.items():
+ canonical = physical.relative_to(root)
+ owners[physical] = (next(iter(relatives)) if len(relatives) == 1
+ else canonical)
+ if canonical in relatives:
+ owners[physical] = canonical
+ return owners
+
+
+def walked_relative_source(path: Path, root: Path, resolved: Path, *,
+ realpath: Callable[[str, str], Path], cwd: str) -> Path:
+ """Reject foreign physical targets before retaining any written alias name.
+
+Inputs are absolute source/root paths; root is the caller's canonical root.
+No root/index/cache state is retained here and no accepted endpoint is minted.
+The fallback handles platform aliases without a corresponding lexical ancestor.
+"""
+ relative = resolved.relative_to(root)
+ try:
+ return path.relative_to(root)
+ except ValueError:
+ for parent in path.parents:
+ try:
+ if realpath(str(parent), cwd) == root:
+ return path.relative_to(parent)
+ except (OSError, RuntimeError):
+ continue
+ return relative
diff --git a/graphify/watch.py b/graphify/watch.py
index 59613b6275..15d9f910e4 100644
--- a/graphify/watch.py
+++ b/graphify/watch.py
@@ -18,6 +18,7 @@
os_replace_with_fallback,
write_text_atomic,
)
+from graphify.publication import ProductPublication
logger = logging.getLogger(__name__)
_PENDING_FILENAME = ".pending_changes"
@@ -825,6 +826,8 @@ def _reconcile_existing_graph(
deleted_source_identities: set[str],
is_ignored_always: Callable[[Path], bool] | None = None,
is_ignored_full: Callable[[Path], bool] | None = None,
+ preserve_sourceless_ast: bool = False,
+ prior_import_ids: set[str] | None = None,
) -> tuple[dict, dict]:
"""Merge fresh extraction with preserved graph entries and evict stale sources.
@@ -836,6 +839,10 @@ def _reconcile_existing_graph(
.gitignore-driven rules, honored only on a full rebuild (an explicit
``graphify update``) so the incremental/hook path keeps preserving a
deliberately-graphed .gitignore'd tree (#1795).
+
+ ``prior_import_ids`` is caller-owned cleanup proof. It receives only
+ persisted source-owned imports before legacy provenance inference; inferred
+ origins are compatibility annotations and cannot authorize removal.
"""
existing_graph_data: dict = {}
if not existing_graph.exists():
@@ -859,6 +866,13 @@ def _reconcile_existing_graph(
existing = json.loads(existing_graph.read_text(encoding="utf-8"))
existing_graph_data = existing
+ # Capture cleanup authority from persisted producer facts before legacy
+ # origin inference can turn an unmarked import into accepted AST evidence.
+ if prior_import_ids is not None:
+ from graphify.build import _EXTERNAL_STUB_RELATIONS
+ from graphify.qt_orphan_cleanup import previous_import_targets
+ prior_import_ids.update(previous_import_targets(existing, import_relations=_EXTERNAL_STUB_RELATIONS))
+
# Backfill tier provenance on legacy items (#2334), mirroring
# build._load_existing_graph (this reconcile path loads the raw dict
# separately, so the backfill there does not reach it). Stamping preserved
@@ -1024,6 +1038,7 @@ def _ignored_now(identity: str) -> bool:
and (
(
not node.get("source_file")
+ and not preserve_sourceless_ast
and (full_rebuild or not code_files)
)
or (
@@ -1132,9 +1147,27 @@ def _node_community_map(graph_data: dict) -> dict[str, int]:
return out
+def _valid_publication_direction(edges, *, directed) -> bool:
+ """Reject corrupt transport before shortcuts can strip markers or accept checkpoints."""
+ from graphify.graph_direction import valid_direction_pairs
+ if valid_direction_pairs(edges, directed=directed):
+ return True
+ print("[graphify watch] error: QT_EXPORT_DIRECTION: invalid logical edge pair; prior products retained. "
+ "Re-extract or repair the graph and retry.", file=sys.stderr)
+ return False
+
+
def _canonical_graph_for_compare(graph_data: dict) -> dict:
canonical = dict(graph_data)
canonical.pop("built_at_commit", None)
+ # Initial extraction and incremental reconciliation omit different empty
+ # run lists. Only their absent/empty forms are equivalent; nonempty values,
+ # malformed transport and unknown metadata remain observable differences.
+ for key in ("diagnostics", "failed_sources", "qml_failures", "qt_failures", "hyperedges"):
+ canonical.setdefault(key, [])
+ # This list describes the current extraction run, not persistent source
+ # facts. Nodes, edges and their authoritative source proof remain compared.
+ canonical.pop("extracted_sources", None)
# A missing "directed" key means the same thing as "directed": false
# everywhere else in the codebase (#2342's --no-cluster path only started
# writing the key once it began inheriting it from the existing graph).
@@ -1312,12 +1345,19 @@ def _stabilize_rebuild_cwd(watch_path: Path) -> bool:
return True
repo_root = os.environ.get("GRAPHIFY_REPO_ROOT", "").strip()
+ # Keep recovery order unchanged, but retain the supplied root's safe failure
+ # category if CWD lookup also fails. Raw paths and OS exception bodies may
+ # contain private repository details and do not belong in this diagnostic.
+ root_failure = (
+ "GRAPHIFY_REPO_ROOT does not name an available directory."
+ if repo_root else "GRAPHIFY_REPO_ROOT is not set."
+ )
if repo_root and Path(repo_root).is_dir():
try:
os.chdir(repo_root)
return True
except OSError:
- pass
+ root_failure = "GRAPHIFY_REPO_ROOT could not be entered."
try:
Path.cwd()
@@ -1325,7 +1365,7 @@ def _stabilize_rebuild_cwd(watch_path: Path) -> bool:
except FileNotFoundError:
print(
"[graphify watch] Rebuild failed: current working directory "
- "no longer exists and GRAPHIFY_REPO_ROOT is not set."
+ f"no longer exists and {root_failure}"
)
return False
@@ -1528,6 +1568,17 @@ def _rebuild_code(
gitignore=_gitignore_enabled,
)
code_files = [Path(f) for f in detected['files']['code']]
+ from graphify.qt_analysis_state import inspect_qt_analysis, commit_qt_analysis
+ qt_state = inspect_qt_analysis(watch_root, out, code_files,
+ excludes=_persisted_excludes, gitignore=_gitignore_enabled)
+ qt_full_refresh = qt_state.has_qt and qt_state.changed
+
+ from graphify.qml_safety import (
+ qml_refresh_required, require_complete_qml, require_qml_watch_root,
+ )
+ # The legacy subfolder rebase changes paths but not nested scoped IDs.
+ # Refuse before extraction/reconciliation rather than publish mixed roots.
+ require_qml_watch_root(code_files, project_root=project_root, watch_root=watch_root)
# #3511: `graphify extract` has surfaced files it saw but could not
# classify since #1692; this update/watch rebuild path never did,
@@ -1697,9 +1748,18 @@ def _add_deleted_source(path: Path) -> None:
# File was deleted or renamed away inside the watched root.
# Evict preserved nodes that still claim this source path.
_add_deleted_source(deleted_in_root)
+ # Shared physical bytes can change every already admitted lexical
+ # owner. Keep semantic documents and discovery outside this boundary.
+ from graphify.watch_coowners import expand_changed_coowners
+ wanted = expand_changed_coowners(wanted, (p for p in code_files if p not in semantic_doc_files), root=watch_root)
from graphify.extractors.terraform import refresh_terraform_paths
wanted = refresh_terraform_paths(wanted, code_files, changed_paths)
- if not wanted and not deleted_paths:
+ # Changes to QML/metadata/embedded JS may invalidate unchanged QML
+ # references. A complete live-code refresh is the safe initial policy.
+ if qt_full_refresh or qml_refresh_required(code_files, changed_paths):
+ wanted = [p for p in code_files if p not in semantic_doc_files]
+ print("[graphify watch] Qt/QML inputs changed; refreshing the live code corpus.")
+ if not wanted and not deleted_paths and not qt_full_refresh:
print("[graphify watch] No tracked code files in change set - skipping rebuild.")
return True
extract_targets = wanted
@@ -1849,12 +1909,22 @@ def _add_deleted_source(path: Path) -> None:
result = extract(
extract_targets,
cache_root=watch_root,
+ qml_import_roots=qt_state.import_roots,
+ refresh_native=qt_state.has_qt,
resolution_context_nodes=resolution_context_nodes or None,
resolution_context_edges=resolution_context_edges or None,
) if extract_targets else {
"nodes": [], "edges": [], "hyperedges": [],
"input_tokens": 0, "output_tokens": 0,
}
+ # The count-based shrink guard cannot account for edge loss or unrelated
+ # additions. QML failure rejects the candidate before any reconciliation.
+ require_complete_qml(result, extract_targets, operation="update/watch", root=watch_root)
+ if project_root != watch_root and any(
+ node.get("metadata", {}).get("qt", {}).get("contract_version") == 1
+ for node in result.get("nodes", [])
+ ):
+ raise ValueError("QML_ROOT_MISMATCH: native Qt scoped facts require the absolute project scan root; prior graph and manifest were preserved")
_rebase_relative_source_files(result, watch_root, project_root)
# #2543: AST sources that failed this run (error result, or extractor
@@ -1863,6 +1933,14 @@ def _add_deleted_source(path: Path) -> None:
# gate reports them unchanged forever and only deleting graphify-out/
# recovers. Mirrors the extract CLI's _stamped_manifest_files handling.
_failed_ast_sources = set(result.get("failed_sources") or [])
+ # A complete accepted Qt refresh may retire stale generic placeholders
+ # only after reconciliation exposes every surviving edge/hyperedge.
+ fresh_ast_ids = {node["id"] for node in result.get("nodes", [])}
+ complete_qt_refresh = bool(
+ qt_state.has_qt and not _failed_ast_sources
+ and {Path(path).resolve() for path in extract_targets}
+ == {Path(path).resolve() for path in code_files if path not in semantic_doc_files}
+ )
def _ast_manifest_files() -> dict[str, list[str]]:
"""detected["files"] minus this run's failed AST sources (#2543).
@@ -1892,6 +1970,22 @@ def _failed(f: str) -> bool:
for ftype, flist in detected["files"].items()
}
+ def _stage_analysis_products(publication: ProductPublication) -> None:
+ """Prepare existing serializers against old seeded bytes, never accepted paths."""
+ from graphify.detect import save_manifest
+ publication.stage(".graphify_root").write_text(
+ _graphify_root_marker_value(watch_path), encoding="utf-8")
+ save_manifest(
+ _ast_manifest_files(), manifest_path=str(publication.stage("manifest.json")),
+ kind="ast", root=watch_root,
+ scan_corpus={f for files in detected["files"].values() for f in files},
+ clear_ast=_failed_ast_sources or None,
+ )
+ if not _failed_ast_sources:
+ commit_qt_analysis(publication.stage(".qt_analysis.json").parent, qt_state)
+
+ accepted_products = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json")
+
# Preserve semantic nodes/edges from a previous full run.
# AST-only rebuild replaces nodes for changed files; everything else is kept.
# Filter by node ID membership in the new AST output, not by file_type —
@@ -1900,6 +1994,7 @@ def _failed(f: str) -> bool:
# When the caller supplied changed_paths, also evict preserved nodes whose
# source_file matches a path that was changed (re-extracted) or deleted —
# otherwise the old nodes for those files would survive forever.
+ prior_import_ids: set[str] = set()
try:
result, existing_graph_data = _reconcile_existing_graph(
existing_graph,
@@ -1909,11 +2004,13 @@ def _failed(f: str) -> bool:
watch_root=watch_root,
code_files=code_files,
extract_targets=extract_targets,
- full_rebuild=changed_paths is None,
+ full_rebuild=changed_paths is None or qt_full_refresh,
deleted_paths=deleted_paths,
deleted_source_identities=deleted_source_identities,
is_ignored_always=_ignored_always,
is_ignored_full=_ignored_full,
+ preserve_sourceless_ast=complete_qt_refresh,
+ prior_import_ids=prior_import_ids if complete_qt_refresh else None,
)
except (RuntimeError, ValueError) as exc:
# Existing graph present but unreadable — over the size cap
@@ -1926,6 +2023,11 @@ def _failed(f: str) -> bool:
print(f"error: {exc}", file=sys.stderr)
return False
+ from graphify.qt_orphan_cleanup import prune_stale_ast_orphans
+ result = prune_stale_ast_orphans(
+ result, fresh_ids=fresh_ast_ids, complete_refresh=complete_qt_refresh,
+ prior_import_ids=prior_import_ids)
+
_relativize_source_files(result, project_root, scope=watch_root)
# Source files re-extracted this run — their symbol sets may legitimately
# shrink (a removed function), so the shrink-guard should not block the
@@ -1972,7 +2074,17 @@ def _failed(f: str) -> bool:
# an import to stdlib / a third-party module leaves an undeclared
# endpoint in graph.json that every loader materialises as an
# attribute-less phantom (#2873).
+ # Publication-created stubs are semantic placeholders from their
+ # first write, matching reconciliation without retagging authored
+ # or source-backed nodes already present in the merged extraction.
+ prior_node_count = len(candidate_graph_data["nodes"])
_mint_external_stubs_in_data(candidate_graph_data)
+ for stub in candidate_graph_data["nodes"][prior_node_count:]:
+ stub.setdefault("_origin", "semantic")
+ if not _valid_publication_direction(
+ ((edge["source"], edge["target"], edge) for edge in candidate_graph_data["links"]),
+ directed=candidate_graph_data["directed"]):
+ return False
candidate_graph_text = _json_text(candidate_graph_data)
same_graph = False
if existing_graph.exists():
@@ -2008,41 +2120,14 @@ def _failed(f: str) -> bool:
return False
from graphify.export import backup_if_protected as _backup
_backup(out)
- # Atomic replace via tmp file, matching the clustered path: a
- # crash mid-write must not leave a truncated graph.json.
- # os_replace_with_fallback, not a plain Path.replace (#2689):
- # this function just read existing_graph a few lines up, and
- # on a VMware HGFS shared folder a replace over a destination
- # read earlier in the same process raises PermissionError
- # even on the same drive.
- graph_tmp = out / ".graph.tmp.json"
- graph_tmp.write_text(candidate_graph_text, encoding="utf-8")
- os_replace_with_fallback(graph_tmp, existing_graph)
-
- # Write the scan root only after the candidate graph is accepted,
- # so a refused shrink cannot mismatch graph and marker. See
- # _graphify_root_marker_value for why this isn't always the raw
- # caller-supplied value (#3375).
- (out / ".graphify_root").write_text(
- _graphify_root_marker_value(watch_path), encoding="utf-8"
- )
-
- try:
- from graphify.detect import save_manifest
- # detected["files"] is a FULL detect of the watched root, so
- # pass it as the scan corpus too: rows for files that left the
- # scan but still exist on disk (newly excluded) are pruned
- # instead of surviving as phantom "deleted" entries (#1908).
- # Failed AST sources are dropped from the stamped set and
- # their prior hashes blanked (#2543).
- save_manifest(
- _ast_manifest_files(), manifest_path=str(out / "manifest.json"),
- kind="ast", root=watch_root,
- scan_corpus={f for _fl in detected["files"].values() for f in _fl},
- clear_ast=_failed_ast_sources or None,
- )
- except Exception:
- pass
+ # Prepare all acceptance products before the first replacement;
+ # an exception restores their old bytes rather than accepting a
+ # graph whose manifest or Qt fingerprint was never committed.
+ with ProductPublication(out, accepted_products, replace=os_replace_with_fallback) as publication:
+ if not same_graph:
+ publication.stage("graph.json").write_text(candidate_graph_text, encoding="utf-8")
+ _stage_analysis_products(publication)
+ publication.commit()
if same_graph:
print("[graphify watch] No code-graph changes detected (--no-cluster); outputs left untouched.")
@@ -2066,6 +2151,8 @@ def _failed(f: str) -> bool:
# update` can't silently downgrade a directed graph to undirected -
# build_from_json defaults to directed=False otherwise.
G = build_from_json(result, directed=bool((existing_graph_data or {}).get("directed", False)))
+ if not _valid_publication_direction(G.edges(data=True), directed=G.is_directed()):
+ return False
candidate_topology = _topology_from_graph(G)
if existing_graph_data:
try:
@@ -2076,18 +2163,9 @@ def _failed(f: str) -> bool:
except Exception:
same_topology = False
if same_topology:
- try:
- from graphify.detect import save_manifest
- # Full-scan save: prune excluded-but-alive rows (#1908);
- # leave failed AST sources unstamped (#2543).
- save_manifest(
- _ast_manifest_files(), manifest_path=str(out / "manifest.json"),
- kind="ast", root=watch_root,
- scan_corpus={f for _fl in detected["files"].values() for f in _fl},
- clear_ast=_failed_ast_sources or None,
- )
- except Exception:
- pass
+ with ProductPublication(out, accepted_products, replace=os_replace_with_fallback) as publication:
+ _stage_analysis_products(publication)
+ publication.commit()
html_action = _reconcile_graph_html(out, existing_graph_data)
if html_action == "rendered":
print(
@@ -2211,7 +2289,6 @@ def _failed(f: str) -> bool:
no_change = same_graph and same_report
if no_change:
graph_tmp.unlink(missing_ok=True)
- print("[graphify watch] No code-graph changes detected; graph.json/GRAPH_REPORT.md left untouched.")
else:
if not _check_shrink(
force, existing_graph_data, candidate_graph_data,
@@ -2221,60 +2298,31 @@ def _failed(f: str) -> bool:
failed_sources=failed_sources,
):
return False
- from graphify.exporters.html import _HTML_STALE_MARKER
- # Mark before graph.json advances so an interruption cannot leave a
- # previous visualization looking current to the fast path.
- (out / _HTML_STALE_MARKER).touch()
from graphify.export import backup_if_protected as _backup
_backup(out)
- # os_replace_with_fallback, not a plain Path.replace (#2689): this
- # function read existing_graph a few lines up for the same_graph
- # comparison, and on a VMware HGFS shared folder a replace over a
- # destination read earlier in the same process raises
- # PermissionError even on the same drive.
- os_replace_with_fallback(graph_tmp, existing_graph)
- write_text_atomic(report_path, report)
- # Keep the membership signatures in step with the labels we just wrote.
- # Skipping this was the other half of the stale-label bug: labels.json
- # advanced every rebuild while the sidecar kept describing an older
- # clustering, so the guard above had nothing accurate to check against.
- #
- # Each write is atomic, so a kill mid-write can never publish a
- # half-written sidecar (a torn one would be unparseable, which the
- # guard reads as "no saved signatures" and falls back to the
- # count heuristic).
- #
- # Labels go down BEFORE the signatures, and that order matters. The
- # guard above compares the SAVED signatures against ones recomputed
- # from the current clustering — not against the labels. So publishing
- # signatures first and crashing would leave a sidecar that already
- # describes the new clustering sitting beside the OLD labels: the
- # guard recomputes the same signatures, finds them equal, reports
- # nothing stale, and silently keeps names that describe a clustering
- # that no longer exists. Writing labels first keeps the sidecar
- # trailing, which the guard sees as a mismatch and re-labels.
- write_text_atomic(labels_file, labels_json)
- write_text_atomic(
- sig_file, json.dumps({str(k): v for k, v in cur_sigs.items()}))
-
- # See _graphify_root_marker_value for why this isn't always the raw
- # caller-supplied value (#3375).
- (out / ".graphify_root").write_text(
- _graphify_root_marker_value(watch_path), encoding="utf-8"
- )
-
+ # The graph, report and labeling sidecars share publication ownership.
+ # Keep stale-marker-before-graph and label-before-signature ordering for
+ # crash recovery; ordinary exceptions roll back the entire cohort.
+ from graphify.exporters.html import _HTML_STALE_MARKER
+ products = (*accepted_products, "GRAPH_REPORT.md", labels_file.name,
+ sig_file.name, _HTML_STALE_MARKER)
try:
- from graphify.detect import save_manifest
- # Full-scan save: prune excluded-but-alive rows (#1908);
- # leave failed AST sources unstamped (#2543).
- save_manifest(
- _ast_manifest_files(), manifest_path=str(out / "manifest.json"),
- kind="ast", root=watch_root,
- scan_corpus={f for _fl in detected["files"].values() for f in _fl},
- clear_ast=_failed_ast_sources or None,
- )
- except Exception:
- pass
+ with ProductPublication(out, products, replace=os_replace_with_fallback) as publication:
+ if not no_change:
+ import shutil
+ publication.stage(_HTML_STALE_MARKER).touch()
+ shutil.copyfile(graph_tmp, publication.stage("graph.json"))
+ graph_tmp.unlink(missing_ok=True)
+ write_text_atomic(publication.stage("GRAPH_REPORT.md"), report)
+ write_text_atomic(publication.stage(labels_file.name), labels_json)
+ write_text_atomic(publication.stage(sig_file.name),
+ json.dumps({str(k): v for k, v in cur_sigs.items()}))
+ _stage_analysis_products(publication)
+ publication.commit()
+ finally:
+ graph_tmp.unlink(missing_ok=True)
+ if no_change:
+ print("[graphify watch] No code-graph changes detected; graph.json/GRAPH_REPORT.md left untouched.")
# Reconcile from the persisted graph. The stale marker was written
# before graph.json advanced, so a failed or interrupted atomic render
@@ -2342,7 +2390,8 @@ def _notify_only(watch_path: Path) -> None:
def _has_non_code(changed_paths: list[Path]) -> bool:
- return any(p.suffix.lower() not in _CODE_EXTENSIONS for p in changed_paths)
+ from graphify.qml_safety import is_qml_path
+ return any(p.suffix.lower() not in _CODE_EXTENSIONS and not is_qml_path(p) for p in changed_paths)
def _batch_triggers_rebuild(batch: list[Path]) -> bool:
@@ -2354,7 +2403,8 @@ def _batch_triggers_rebuild(batch: list[Path]) -> bool:
this, a doc-only deletion batch would sit behind the needs_update flag
until the next code event or a manual `graphify update` (#2580).
"""
- has_code = any(p.suffix.lower() in _CODE_EXTENSIONS for p in batch)
+ from graphify.qml_safety import is_qml_path
+ has_code = any(p.suffix.lower() in _CODE_EXTENSIONS or is_qml_path(p) or p.name in {".graphifyignore", ".gitignore"} for p in batch)
has_deletion = any(not p.exists() for p in batch)
return has_code or has_deletion
@@ -2423,24 +2473,29 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None:
class Handler(FileSystemEventHandler):
def on_any_event(self, event):
- nonlocal last_trigger, pending
+ nonlocal last_trigger, pending, ignore_patterns
if event.is_directory or _is_read_only_event(event):
return
path = Path(os.fsdecode(event.src_path))
+ policy_change = path.name in {".graphifyignore", ".gitignore"} and _is_relative_to(path, watch_root_for_ignore)
+ if policy_change:
+ ignore_patterns = _load_graphifyignore(watch_root_for_ignore,
+ gitignore=_read_build_gitignore(watch_path / _GRAPHIFY_OUT))
# Check .graphifyignore BEFORE the extension/dotfile/out filters so
# the cheapest short-circuit for users with broad ignore patterns
# (node_modules/, .venv/, build/, …) fires first. _is_ignored
# tolerates absolute paths outside watch_root via its internal
# relative_to guard, so a stray symlinked event won't raise.
- if ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns):
+ if not policy_change and ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns):
return
- if path.suffix.lower() not in _WATCHED_EXTENSIONS:
+ from graphify.qml_safety import is_qml_path
+ if not policy_change and path.suffix.lower() not in _WATCHED_EXTENSIONS and not is_qml_path(path):
return
try:
filter_parts = path.relative_to(watch_root_for_ignore).parts
except ValueError:
filter_parts = path.parts
- if any(part.startswith(".") for part in filter_parts):
+ if any(part.startswith(".") for part in (filter_parts[:-1] if policy_change else filter_parts)):
return
if _GRAPHIFY_OUT in filter_parts:
return
diff --git a/graphify/watch_coowners.py b/graphify/watch_coowners.py
new file mode 100644
index 0000000000..96e36ab51d
--- /dev/null
+++ b/graphify/watch_coowners.py
@@ -0,0 +1,69 @@
+"""Expand incremental work to already admitted owners of changed physical bytes.
+
+Detection, exclusions, deletion handling, extraction and publication remain with
+the watch caller. This boundary reads only filesystem identity, never source
+content, and retains no cache or mutable state after one selection operation.
+"""
+from __future__ import annotations
+
+from collections.abc import Iterable
+import json
+import os
+from pathlib import Path
+import stat
+
+
+SOURCE_CONTEXT_LIMIT = 160
+
+
+def _safe_source(path: Path, root: Path) -> str:
+ """A failed identity lookup cannot justify resolving a diagnostic path again."""
+ try:
+ relative = path.relative_to(root).as_posix()
+ except ValueError:
+ return ""
+ if len(relative) > SOURCE_CONTEXT_LIMIT or any(
+ ord(char) < 32 or 127 <= ord(char) < 160 or char in "\u2028\u2029"
+ for char in relative):
+ return ""
+ return relative
+
+
+def physical_identity(path: Path, root: Path) -> tuple:
+ """Verify a regular contained input before comparing shared physical content."""
+ try:
+ resolved = path.resolve(strict=True)
+ resolved.relative_to(root)
+ value = path.stat()
+ if not stat.S_ISREG(value.st_mode):
+ raise ValueError("not a regular admitted source")
+ if value.st_dev and value.st_ino:
+ return "inode", value.st_dev, value.st_ino
+ # Some filesystems do not expose inode identity. A canonical path still
+ # proves symlink/junction co-ownership without guessing about hardlinks.
+ return "path", resolved
+ except (OSError, RuntimeError, ValueError):
+ source = json.dumps(_safe_source(path, root), ensure_ascii=True)
+ raise ValueError(f"WATCH_SOURCE_IDENTITY_FAILED: source={source}; admitted source "
+ "identity unavailable; prior products retained; repair and retry") from None
+
+
+def expand_changed_coowners(wanted: Iterable[Path], corpus: Iterable[Path], *, root: Path) -> list[Path]:
+ """Append verified co-owners without authorizing discovery or deleted inputs.
+
+ The caller supplies only positive, admitted, nonsemantic source paths. Keep
+ notification order and append additional owners in accepted corpus order.
+ A failed lookup refuses partial work so publication can retain its cohort.
+ """
+ wanted = list(wanted)
+ if not wanted:
+ return wanted
+ accepted = {Path(os.path.abspath(path)): Path(path) for path in corpus}
+ changed = {Path(os.path.abspath(path)) for path in wanted}
+ if not changed <= accepted.keys():
+ raise ValueError("WATCH_SOURCE_IDENTITY_FAILED: source=\"\"; changed source is not "
+ "admitted; prior products retained; repair and retry")
+ identities = {path: physical_identity(path, root) for path in accepted}
+ affected = {identities[path] for path in changed}
+ return wanted + [original for path, original in accepted.items()
+ if path not in changed and identities[path] in affected]
diff --git a/pyproject.toml b/pyproject.toml
index e910975a8f..f393ab2dc1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -111,6 +111,8 @@ vbnet = ["tree-sitter-vb-dotnet==0.3.0"]
# language pack bundles both grammars in a prebuilt, offline-compatible wheel.
# Optional extras (large shared dep, resolved once in `all`).
r = ["tree-sitter-language-pack==0.11.0"]
+# QML-00 verified the offline qmljs grammar/API; keep the shared pack optional.
+qml = ["tree-sitter-language-pack==0.11.0"]
erlang = ["tree-sitter-language-pack==0.11.0"]
solidity = ["tree-sitter-solidity==1.2.13"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.16.1", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "graspologic-native>=1.3.1,<2; python_version >= '3.13'", "python-docx", "openpyxl", "psycopg[binary]", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.7.4", "matplotlib", "pillow>=12.3.0", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba; python_version < '3.12'", "jieba-py>=0.46.12,<1; python_version >= '3.12'", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "robotframework>=4.0", "tree-sitter-vb-dotnet==0.3.0", "tree-sitter-language-pack==0.11.0", "tree-sitter-solidity==1.2.13"]
diff --git a/tests/TRACEABILITY.md b/tests/TRACEABILITY.md
new file mode 100644
index 0000000000..7f76253336
--- /dev/null
+++ b/tests/TRACEABILITY.md
@@ -0,0 +1,1740 @@
+# Acceptance traceability
+
+## Upstream submission scope
+
+The [scoped upstream contribution](../docs/qt-qml/UPSTREAM_CONTRIBUTION.md) retains bounded Qt/QML analysis
+and its source, incremental, publication and consumer contracts. REQ-QML-019
+and REQ-QML-021, D12/D16, and the generic viewer portions of INC-QML-09/13/16
+are deferred for this submission. Native Codex REQ-CORE-001-AC01/AC05 and the
+broad fork repository policy are also deferred; the baseline installer and
+repository policy apply. Constructor and source-containment work in INC-QML-13
+remains in scope.
+
+Aggregate source-edge counts also remain as a REQ-QML-020-AC01 dependency,
+tested by `tests/test_qt_project_membership_updates.py::test_req_qml020_ac01_unused_packaged_component_survives_aggregate_export`
+through the existing upstream inspector harness. That partial source-count
+projection does not complete deferred REQ-QML-019-AC02. Startup selection is not
+part of the retained membership criterion.
+
+### Scoped local preparation — INC-QML-53
+
+The first 135-module Qt/QML source selection passes 2,379 tests with 38 skips
+and fails the aggregate membership case because its generic inspector helper
+was removed. The skips are three missing wheel-input cases, nineteen unavailable
+native symlink-capability cases and sixteen POSIX link/parent semantics cases.
+The correction preserves the two external links, degree, positive internal count
+and total source-edge accounting. The new parametrized
+`tests/test_qt_project_membership_updates.py::test_req_qml020_ac01_aggregate_counts_keep_source_edges_and_reject_invalid_fields`
+also checks parallel/directed/self-loop conservation, unchanged input graph and
+rejection of boolean, negative and injected string counts through production HTML.
+All four aggregate cases fail before the correction.
+
+Executed corrected command:
+`python -X utf8 -m pytest tests/test_qt_project_membership_updates.py tests/test_qt_graph_html_payload.py tests/test_qt_html_consumers.py tests/test_export.py tests/test_cli_export.py -q --tb=short -p no:cacheprovider -rs`:
+**145 passed, zero failures/skips**. Focused Ruff and explicit-interpreter Pyright
+pass. HTML is 717 lines within its 760-line scope ceiling; membership tests are
+299 lines within 300. Whole-project typing remains the recorded baseline gap.
+
+`python -X utf8 -m pytest tests/test_install.py tests/test_install_references.py tests/test_install_roundtrip.py tests/test_skill_auto_refresh.py tests/test_uninstall_scope.py tests/test_settings_merge.py -q --tb=short -p no:cacheprovider -rs`:
+**265 passed, five skipped**. Four require unavailable native symlink capability;
+one progressive fixture profile has no applicable shipped assistant.
+
+`uv build --wheel --offline` with a fresh setuptools build base produces the
+scoped wheel; all 182 packaged Python files match source bytes and deferred
+hook/community/navigation modules are absent. The wheel SHA-256 is
+`c9b413aac68289a2055baf1d70f7efda3fb1b8a4554ad7bb81b0ef6c94e90439`.
+With that wheel as `GRAPHIFY_QML_TEST_WHEEL`,
+`python -X utf8 -m pytest tests/test_qml_platform_matrix.py tests/test_qml_wheel_artifact.py tests/test_wheel_packaging.py -q --tb=short -p no:cacheprovider -rs`:
+**138 passed, zero failures/skips**, separately clearing the three artifact-input
+exclusions. Other source skips remain gaps on this local profile. AST-only graph
+refresh completes with 23,096 nodes/53,444 edges and no provider calls. These
+local results do not replace final-head normal hosted validation.
+
+Two fresh offline environments install the attested wheel with `[qml,watch]`
+and with core dependencies only. Isolated `python -I tests/qml_installed_smoke.py`
+and its `--core-only` profile pass from a neutral working directory on Windows
+Python 3.12.14/tree-sitter 0.25.2. Actual parser absence, safe unrelated extraction,
+native emission and optional native/module joins execute through installed
+production entry points. The smoke's audit guard rejects network/process use
+during analysis. Full `python -m pyright --pythonpath `
+still fails with 605 errors/zero warnings; focused touched-code typing passes.
+Comparing file/severity/rule/message multiplicities with the recorded integration
+checkpoint finds zero added or removed diagnostic signatures. Source-line shifts
+are excluded from that comparison; unchanged errors remain failures.
+
+The complete native command shown above finishes **9,202 passed, 45 failed,
+122 skipped**. JUnit identifies all 45 failures as inaccessible WindowsApps
+Bash alias / missing `sh` setup in `tests/test_hooks.py` (13),
+`tests/test_shell_portability_helpers.py` (6) and
+`tests/test_skillgen_input_path_injection.py` (26). No Qt/QML assertion fails:
+the 137 Qt/QML modules report **2,392 passed, zero failed, 38 skipped**.
+The full run also excludes sixteen shell-dependent cases in hooks and
+`tests/test_hook_chain_survives_skip.py`. The initial incomplete-bundle retry
+reports 157 passed, seven failed and eight skipped; failures require adjacent
+DLLs or the absent path mapper. The admission owner configures complete portable
+Git for Windows 2.56.0 in test-process PATH and reruns four unchanged modules.
+The [official release](https://github.com/git-for-windows/git/releases/tag/v2.56.0.windows.1)
+archive has verified SHA-256
+`eceb5e061aa90df2f69ddd3e90f0030e1b8037a7829934bc40e4be1caa1accc1`.
+The initial full failure remains evidence; a focused corrected result does not
+claim a repeated whole-suite pass. Native symlink/POSIX, compiler, service and
+other documented profile exclusions remain separate.
+
+Corrected command:
+`python -X utf8 -m pytest tests/test_hooks.py tests/test_shell_portability_helpers.py tests/test_skillgen_input_path_injection.py tests/test_hook_chain_survives_skip.py -q --tb=short -p no:cacheprovider -rs`:
+**171 passed, zero failed, eight skipped** under the CI-compatible isolated PATH,
+exact Python shim and offline installer guards. Python 3.12.14, Bash/sh 5.3.15,
+cygpath 3.6.10, Git 2.56.0.windows.1 and Node 24.19.0 execute actual production
+consumers. The eight exclusions are two unavailable symlink cases, one POSIX
+executable-shim case and five explicit native-Windows hook exclusions. Failed
+whole-suite and incomplete-bundle receipts remain separate from this result;
+no repeated whole-suite pass is claimed. The original local coverage file is
+unchanged and tooling stays outside published sources.
+
+The [complete frozen fork](https://github.com/SlinkyRamey/graphify/commit/82a4f296446b4cc219ffecfe3345a037e9c362e4)
+and its recorded 8b6c9d2 source/runner evidence retain their own revision scope.
+Deferred feature sections and their exact tests below are historical evidence,
+not implemented or verified contracts of this narrower contribution. A changed
+contribution head requires fresh applicable checks; prior totals, skips and
+hosted results do not establish those checks.
+
+Current criterion IDs use `REQ-QML-`; completion increments use `INC-QML-`.
+[Legacy aliases](../docs/qt-qml/IDENTIFIERS.md) retain earlier evidence identity.
+Recorded test identities retain their historical evidence; deferred viewer and
+native-hook tests are mapped to their frozen owners below. Source annotations
+keep their IDs.
+
+The current catalog contains twenty-one requirements and eighty-five stable
+acceptance criteria. REQ-QML-018 has seven criteria, including the separate AC07
+header-dispatch contract; REQ-QML-019/020/021 retain four/three/three criteria.
+INC-QML-00–07 hosted proof describes its original seventeen-requirement,
+sixty-eight-criterion profile. Subsequent evidence does not retroactively broaden
+those historical results.
+
+INC-QML-11/15/08a/b/c and INC-QML-28–38 have recorded local source, update, consumer
+and reviewed installed-wheel evidence. All seven REQ-QML-018 criteria are locally
+verified for the bounded Windows x64/Python 3.12 profile. Individual assignments
+and the [final local matrix](#final-adoption-delivery) distinguish passing cases
+from wider platform, native interaction, live-service and runtime limitations.
+The recorded full source run has 59 baseline failures and 178 skips, with no added
+failure identities; full typing remains a failed baseline gate. The later
+[Windows dependency follow-up](../docs/qt-qml/VALIDATION.md#windows-shell-and-optional-dependency-follow-up--2026-10-04)
+clears 40 of those 59 in a selective rerun, retains 19, and exposes two previously
+skipped hook portability assertions. It does not replace full-suite evidence.
+Neither aggregate test totals nor skipped cases establish acceptance.
+
+The separate [shared compatibility verification](#shared-compatibility-verification)
+records `REQ-CORE-` behavior and the all-extras environment. The recorded native
+Windows full run at `463ca32` has 8,818 passes, zero failures and 151 skips. Separate unchanged-
+source profiles pass all 76 Node and seven preprocessor/wheel cases previously
+skipped for setup. Later real Linux/service and native checks pass at the
+[verified integrated checkpoint](#verified-integrated-hosted-checkpoint--8b6c9d2). The
+59-failure run above is historical; its failure identities are cleared in the
+recorded applicable Windows profile. Full typing still fails on existing errors.
+
+The chronological records below preserve checkpoint evidence. Their older pending
+statements, epochs and test totals describe the recorded revisions. Complete-fork
+status and proof are in the [verified integrated checkpoint](#verified-integrated-hosted-checkpoint--8b6c9d2),
+with earlier adoption evidence retained in
+[validation](../docs/qt-qml/VALIDATION.md#final-adoption-delivery).
+See [implementation limits](../docs/qt-qml/IMPLEMENTATION.md#final-adoption-delivery),
+[export contracts](../docs/qt-qml/EXPORT_MATRIX.md) and
+[platform matrix](../docs/qt-qml/PLATFORM_MATRIX.md).
+
+## Deferred criterion assignments for upstream submission
+
+These assignments preserve the catalog and recorded evidence without claiming
+that excluded features pass on the narrowed contribution. REQ-QML-001–018/020
+and retained shared compatibility criteria keep their behavioral acceptance
+unchanged and require evidence applicable to the submitted revision. The retained
+Qt aggregate-omission regression does not verify the deferred generic viewer.
+
+| Criterion | Current contribution assignment | Evidence / follow-up owner |
+| --- | --- | --- |
+| REQ-QML-019-AC01 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below |
+| REQ-QML-019-AC02 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below |
+| REQ-QML-019-AC03 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below |
+| REQ-QML-019-AC04 | Deferred for this upstream submission; not a current acceptance gate | Separate generic-viewer contribution; frozen complete-fork evidence below |
+| REQ-QML-021-AC01 | Deferred for this upstream submission; not a current acceptance gate | Separate native-navigation contribution; frozen complete-fork evidence below |
+| REQ-QML-021-AC02 | Deferred for this upstream submission; not a current acceptance gate | Separate native-navigation contribution; frozen complete-fork evidence below |
+| REQ-QML-021-AC03 | Deferred for this upstream submission; not a current acceptance gate | Separate native-navigation contribution; frozen complete-fork evidence below |
+| REQ-CORE-001-AC01 | Deferred for this upstream submission; not a current acceptance gate | Separate native Codex installer contribution; frozen complete-fork evidence below |
+| REQ-CORE-001-AC05 | Deferred for this upstream submission; not a current acceptance gate | Separate native Codex installer contribution; frozen complete-fork evidence below |
+
+## Shared compatibility verification
+
+The Windows x64/Python 3.12 checkpoint installs all extras from the unchanged
+lock with `uv sync --all-extras --frozen`; `uv pip check` reports 197 compatible
+packages. Tree-sitter remains 0.25.2, HCL 1.2.0 and language-pack 0.11.0.
+The [compatibility plan](../docs/COMPATIBILITY.md) records profile setup, exact
+remaining system procedure, ownership, size ceilings and consumer limitations.
+
+| Acceptance ID | Exact automated assignment | Checkpoint evidence and gaps |
+| --- | --- | --- |
+| REQ-CORE-001-AC01 | [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac01_codex_hook_dispatches_spaced_native_launcher`); `tests/test_install.py::test_codex_hook_command_is_a_real_cli_subcommand` | Historical complete-fork evidence; deferred for this submission. Exact Cmd and Windows PowerShell 5.1 argument transports pass with a real selected launcher, failing PATH decoy and unsafe control; full-source and independent installed-artifact execution pass in the current native evidence below |
+| REQ-CORE-001-AC02 | `tests/test_install.py::test_hermes_skill_destination_posix_uses_home`; `tests/test_install_references.py::test_gemini_install_references_all_resolve`; `tests/test_install_roundtrip.py::test_skill_roundtrip_at_real_destination` | Exact destination and Linux/Windows contract profiles pass locally; simulated platform selection does not prove another kernel |
+| REQ-CORE-001-AC03 | `tests/test_skill_auto_refresh.py::test_every_stale_platform_is_refreshed_not_only_the_detected_one`; `tests/test_skill_auto_refresh.py::test_a_stale_gemini_skill_gets_the_warning_too` | Windows-adapted bytes and retained ambiguous shared copy pass; shared ownership isolation remains unresolved upstream |
+| REQ-CORE-001-AC04 | `tests/test_uninstall_scope.py::test_bare_call_still_removes_global`; `tests/test_uninstall_scope.py::test_remove_user_skill_opt_in_with_project_dir` | Linux/Windows scope profiles and protected shared-dir retention pass; no new uninstall policy |
+| REQ-CORE-001-AC05 | [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_literal_expansion_characters_retain_launcher_identity`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_missing_launcher_fails_without_path_fallback`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_selected_process_failure_preserves_nonzero_exit`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_reinstall_and_uninstall_preserve_unrelated_hooks`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_unsupported_shell_rejects_before_settings_access`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_shell_inspection_failure_retains_settings_and_backup`); [frozen test_codex_hook_execution owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_codex_hook_execution.py) (`test_req_core001_ac05_command_length_boundary_retains_literal_identity`) | Historical complete-fork evidence; deferred for this submission. Native Cmd/Windows PowerShell 5.1 executable identity, failure/retention and rejected serialization boundaries pass in full-source and independent installed-artifact execution. Synthetic length cases establish admission/retention, not a real long-path launch. Actual Codex event delivery and other PowerShell versions remain separate system gaps. |
+| REQ-CORE-002-AC01 | `tests/test_filesystem_profiles.py::test_req_core_002_ac01_detect_rejects_nonregular_stat_modes`; `tests/test_filesystem_profiles.py::test_req_core_002_ac01_stat_failures_are_unreadable`; original real fixtures in `tests/test_non_regular_files.py` | Native admission/error seams and hosted real FIFO/socket cases pass at the verified 10cb15a checkpoint below; privileged device/link fixtures retain explicit capability exclusions |
+| REQ-CORE-002-AC02 | `tests/test_filesystem_profiles.py::test_req_core_002_ac02_unavailable_cwd_rejects_before_artifacts`; `tests/test_filesystem_profiles.py::test_req_core_002_ac02_repo_root_recovers_cwd_and_publishes_graph`; `tests/test_watch.py::test_rebuild_code_deleted_cwd_without_repo_root_returns_false`; `tests/test_watch.py::test_rebuild_code_deleted_cwd_uses_graphify_repo_root` | Native simulated lookup-failure and real chdir/persistence pass; actual removed-CWD POSIX cases pass individually in all four 10cb15a Linux lanes below |
+| REQ-CORE-002-AC03 | `tests/test_filesystem_profiles.py::test_req_core_002_ac03_unavailable_cwd_reports_root_reason` | Missing-root/chdir-denied assertions failed before correction; all three safe diagnostic categories pass after correction |
+| REQ-CORE-003-AC01 | `tests/test_terraform_modules.py::test_same_named_directories_and_cross_file_references_stay_separate` | Both LF/CRLF cases pass exact four identities/references and exclude cross-directory targets |
+| REQ-CORE-003-AC02 | `tests/test_terraform_modules.py::test_same_named_directories_and_cross_file_references_stay_separate`; `tests/test_terraform_modules.py::test_raw_extractor_scopes_ids_by_full_directory` | Production extraction, directed build and JSON reload pass portable paths, locations and scope; Terraform production unchanged |
+
+Focused commands and results at the checkpoint:
+
+- Final INC-CORE-05 consumer correction: [frozen historical invocation](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/TRACEABILITY.md#L68) passes 19 cases, with no skips and one existing Hypothesis discovery warning. Both actual Windows shells exercise the persisted command. Installer/settings-merge/hook characterization passes 143 cases with three existing profile skips; these selections overlap and are not added to the full-suite count.
+- [frozen historical invocation](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/TRACEABILITY.md#L69): 243 passed, five native symlink skips. Strengthened exact-launcher/subcommand follow-up: two passed.
+- `pytest tests/test_hooks.py tests/test_skillgen_input_path_injection.py -q`: 143 passed, eight pre-existing profile skips. The final affected selection plus shell-identity helpers: 37 passed, no skips. All 20 hostile-path cases include reachable unsafe controls.
+- `pytest tests/test_watch.py tests/test_non_regular_files.py tests/test_filesystem_profiles.py -q`: 198 passed, eleven profile/capability skips, one existing Hypothesis warning. Seven formerly failing impossible Windows fixtures now remain explicit system gaps; sixteen new portable cases pass.
+- `pytest tests/test_terraform.py tests/test_terraform_modules.py tests/test_extractors_registry.py::test_terraform_migrated -q`: 42 passed, one unavailable symlink skip, one existing Hypothesis warning.
+- `ruff check .`: passed. `python -m tools.skillgen --check`: all 134 artifacts match. Lockfile unchanged. Full native source and refreshed installed-wheel evidence supersedes the restart gap in the current record below; actual Linux execution remains pending.
+
+These selections overlap and are not added into a full-suite total. Real filesystem
+capability assertions, native hook consumer execution and live-service tests remain
+separate from contract profiles and fake-provider boundaries. Full typing is a
+separate gate and is not established by Ruff or selected tests.
+
+### Current native source and artifact evidence — 2026-10-05
+
+Frozen source: `463ca3226c4aec4390a1e931fb1be1582059d1ca`, Windows x64,
+Python 3.12.14, all 197 locked source-environment distributions. The full source
+command is the isolated driver's `pytest tests/ -q --tb=short -p no:cacheprovider
+-rs --junitxml=/full.xml`, using the explicit environment Python with
+`-I -X utf8` and canonical source imports. The profile exposes portable Git
+Bash/sh and a literal Python forwarding shim, isolates provider/home/install
+state and disables installation fallback. Pytest returns zero: **8,818 passed,
+zero failures/errors, 151 skipped, 20 warnings**, with 1,143.29 seconds wall time.
+
+The original restricted PATH excluded the already installed Node and GNU C
+preprocessor, and the reviewed-wheel variable was absent. Supplemental profiles
+retain that same source/dependency revision and execute exact skipped IDs:
+
+- Node 24.19.0: **76 passed**, zero failures/skips, 26.42 seconds.
+- GNU `cpp` 13.1.0 and reviewed wheel input: **four Fortran preprocessor and three wheel cases passed**, zero failures/skips, 4.68 seconds. This compiler is a test oracle; Graphify analysis gains no compiler or Qt SDK runtime dependency.
+
+The full-run counts are retained without adding these separate profiles. The
+remaining 68 distinct initial skips are 63 Windows/POSIX capability or fixture
+exclusions, two other-Python-version cases, one inapplicable obsolete bundle
+fallback and two unavailable FalkorDB integration cases. Skips remain exclusions,
+not passes. The hosted increment assigns applicable Linux/service cases explicitly.
+The 20 warnings are nine clustering dependency warnings, seven Solidity parser
+deprecations, two expected cache-rejection diagnostics, one Hypothesis collection
+warning and one Starlette/httpx deprecation; no warning caused a failed assertion.
+
+All three profiles retain HEAD, 667 Python-file fingerprints, source dependencies
+and original coverage bytes. The canonical source-fingerprint map SHA-256 is
+`36155c56557d2a52bb8384fa8964581de57cc22c9b5ec46703d0fb8f0916e291`.
+Full Pyright 1.1.409 with the explicit source interpreter reports **604 errors,
+zero warnings, 663 files**, exit one. Compared with immutable source `135d50b`
+under the same dependencies/configuration, all 604 diagnostic identities match:
+zero added and zero removed. The typing gate remains failed.
+
+Reviewed wheel SHA-256:
+`057c509296b9f6e25be1dd39b72f3d463f10cdccfb7a0b95836f510f016dd518`.
+All 180 packaged Python payloads match current source bytes. Relative to the
+earlier reviewed 179-module wheel, 102 differences are only LF/CRLF spelling;
+newline-normalized source and parsed syntax differ only in `install.py`,
+`watch.py` and the new [frozen codex_hook_command owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/graphify/codex_hook_command.py). Independent installed consumption
+pins all Graphify imports to its 163-distribution runtime from a neutral directory:
+**35 hook/filesystem cases pass**, with zero failures/skips. `uv pip check`
+confirms compatibility. This is complementary installed-consumer evidence,
+not a replacement for the earlier Qt/QML artifact matrix or a hosted result.
+
+### Hosted acceptance assignments
+
+The CI implementation and exact hosted execution remain separately reviewable.
+Each job's JUnit artifact and GitHub conclusion must be read for the same tested
+source/configuration revision; configuration validation alone cannot pass these
+criteria. [Hosted procedure](../docs/COMPATIBILITY.md#hosted-runner-proof-procedure)
+owns setup, revision identity, exclusions and recovery.
+
+| Acceptance ID | Exact automated job/system assignment | Current evidence |
+| --- | --- | --- |
+| REQ-CORE-004-AC01 | `.github/workflows/ci.yml::test` four Python lanes; actual fixtures in `tests/test_non_regular_files.py`, `tests/test_cpp_preprocess.py`, `tests/test_qml_wheel_artifact.py`, `tests/test_falkordb_integration.py` | Hosted `10cb15a`: all four full jobs pass; required Linux/service/wheel regressions and exact corrected cleanup case individually pass. Explicit platform/baseline skips retain their assignments |
+| REQ-CORE-004-AC02 | `.github/workflows/ci.yml::windows-compatibility`; exact CORE functions above and assigned HTML/installer/shell modules | Hosted `10cb15a`: 836 passes, 21 explicit skips, zero failures. All original shell failures and revised admission cases individually pass with retained consumer identity proof |
+| REQ-CORE-004-AC03 | Source identity/readiness, pytest, retention, service cleanup and artifact steps in both CI source profiles; GitHub run/job/artifact read for actual checkout | Hosted `10cb15a`: all five API-digest-verified source archives retain identity/JUnit/integrity; native report includes exact two-shell consumer identities. Four real services/test graphs are confirmed removed |
+| REQ-CORE-004-AC04 | CI trigger/permission/concurrency configuration review and hosted procedure's PR/recovery event audit | Normal PR events own both workflows; no duplicate dispatch. All four `10cb15a` source lanes finish successfully; earlier matrix cancellations remain historical incomplete evidence |
+
+Local configuration review parses both workflows with PyYAML `BaseLoader`,
+compiles each embedded Python block, checks extracted Ubuntu scripts with
+`bash -n`, and parses the Windows script through PowerShell's
+`System.Management.Automation.Language.Parser.ParseFile`. The CI workflow is
+449 physical lines within its documented 450-line cohesion ceiling. Existing
+triggers, Ubuntu matrix, skill-generation/security jobs and QML-wheel matrix/job
+selection remain intact. Both workflow concurrency groups now use distinct run
+IDs outside PR events. These are local configuration results, not hosted passes.
+
+At first publication `3b005d3`, the delivery changes only CI and documentation
+after the completed native run: all 667 Python-file fingerprints and 197 dependency versions match the
+tested source, and original coverage bytes are preserved. `ruff check .` passes,
+`python -m tools.skillgen --check` matches all 134 artifacts, and the document
+check resolves every acceptance criterion and exact test reference. The graph
+refresh is AST-only navigation maintenance, not additional acceptance evidence.
+
+The first hosted source head `3b005d3` produced
+[CI admission failure 37244260265](https://github.com/SlinkyRamey/graphify/actions/runs/37244260265)
+before creating any jobs: job-level `env` cannot reference `runner.temp`.
+Resolving the evidence directory inside each Ubuntu script from `RUNNER_TEMP`
+preserves the same artifact location and also admits the independent cleanup
+step after failed setup. Official checksum-verified actionlint 1.7.12 rejects
+the original configuration with that context error and accepts both corrected
+workflows with zero diagnostics, without suppressions. This semantic check
+complements YAML/Python/native shell syntax; ShellCheck and Pyflakes were not
+installed for the actionlint run. No Linux/service pass is inferred from the
+admission failure or local correction.
+
+At the same source head,
+[QML wheel run 37244275402](https://github.com/SlinkyRamey/graphify/actions/runs/37244275402)
+passes all four Ubuntu artifact lanes but fails the installed native-module
+smoke assertion in all four Windows and all four macOS lanes. These are current
+failed gates under investigation, not skips or passing platform acceptance.
+
+### Canonical accepted-input identity — INC-QML-39
+
+The first hosted smoke failure is reproduced locally with a real Windows
+short-path alias. Canonical spelling passes; the unchanged installed smoke
+fails through the shortened spelling before the correction. The membership
+boundary now compares canonical contained input identity against the accepted
+declaration. Stored lexical provenance, literal lookup, Qt policy 18 and AST
+schema 12 retain their contracts. This local reproduction explains the Windows
+failure; matching macOS temporary-directory aliases remain a hypothesis until
+the corrected hosted lane passes.
+
+| Acceptance ID | Exact automatically collected assignment | Evidence and limits |
+| --- | --- | --- |
+| REQ-QML-020-AC01 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac01_real_parent_alias_preserves_smoke_and_cold_warm_identity` | Unchanged native smoke, canonical/alias/reanchored identity, immutable source bytes and warm unrelated Python cache; real Windows short path passes, portable parent symlink requires host capability |
+| REQ-QML-020-AC02 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac02_path_alias_cannot_authorize_foreign_source`; `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac02_input_resolution_failure_is_bounded_and_preserves_facts`; `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac02_conflicting_transport_retains_durable_products_and_recovers` | Outside-root/conflicting input and OSError/RuntimeError reject boundedly without changing facts; real manual/watch publication preserves graph, manifest, stamp and root marker, then recovers and repeats |
+| REQ-QML-020-AC03 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac03_alias_updates_remove_stale_membership_and_match_full_rebuild` | Real manual/watch build-declaration removal retires its stale site/edges, matches canonical full rebuild and repeats idempotently |
+
+Before the production correction, the alias selection records five failures,
+two passing rejection controls and seven unavailable-symlink skips. After the
+correction, alias plus existing project-membership/lifecycle selections pass
+48 cases with seven capability skips and one existing Hypothesis warning.
+The seven skips are real directory-symlink capability exclusions on this Windows
+host; they do not establish portable alias acceptance. The production owner is
+198 physical lines; the new test module is 222, within the 300-line ceiling.
+The frozen broader source selection passes 2,259 cases with nine capability
+skips, two warnings and zero failures/errors in 527.19 seconds wall time. Its
+command uses the isolated source interpreter with all `tests/test_qml_*.py` and
+`tests/test_qt_*.py` files except the separately assigned wheel-artifact module,
+then `-q --tb=short -p no:cacheprovider -rs --junitxml=REPORT`. All 668 Python-file
+fingerprints, 197 dependencies and original coverage bytes remain unchanged;
+the source-map digest is
+`c2ad0bc662ab2780385766dd22fc56d0bf6a4f5cb059e4e960d61979b60878d4`.
+The nine skips are the seven new and two pre-existing real-symlink exclusions.
+The two warnings are existing Hypothesis collection and Starlette/httpx notices.
+This selection overlaps earlier runs and is not added to their totals.
+
+Fresh wheel SHA-256
+`f6a3c7befa8cae62ef70a6d89649b64cdc409fb25056d2ee383166c7fe8be522`
+contains 180 Python payloads identical to source, changing only the membership
+owner relative to the preceding core-05 wheel. Ordinary QML and core-only smoke
+pass in clean constrained installed environments; every loaded production module
+comes from the independent installation. A near-root real Windows temporary
+short alias fails with `QT_CPP_LIMIT`: shared facade normalization publishes
+traversal source provenance for contained C++ files. The same case fails in fresh
+source with and without the offline guard. INC-QML-40 owns this separate defect;
+the native traversal rejection is correct and is retained. Complete installed
+alias acceptance and corrected hosted proof remain outstanding.
+Existing REQ-QML-004-AC04 and
+REQ-QML-012-AC01/AC02 containment/failure assignments remain applicable; no
+new corpus reader, persistence owner or diagnostic code is introduced.
+
+On the frozen 198/222-line production/test payload, Ruff 0.15.14 passes and
+targeted Pyright 1.1.409 reports zero errors/warnings. A fresh whole-project
+comparison uses the same Python 3.12.14, 197 dependencies and typing configuration
+for current source and immutable `135d50b`: both report 604 errors, zero warnings,
+with exactly 604 shared diagnostic identities and zero added/removed. Current
+source analyzes 664 files in 24.022 seconds; baseline analyzes 658 in 22.615.
+Both whole-project commands exit one. The expanded alias coverage adds no typing
+diagnostic; it does not pass the existing whole-project typing gate.
+
+The failure-boundary review separately reproduces persistent OSError and
+RuntimeError at the accepted CMake input after source/index creation, through
+direct joins and manual/watch writers. Diagnostic construction resolves the
+failed input again and rethrows its raw body, leaving later sources unannotated.
+Manual exits one and watch returns false; graph/manifest/stamp/root-marker bytes,
+repair and no-change repeat pass. The six opt-in probes fail the bounded-body
+acceptance assertion. INC-QML-41 owns ordinary regression promotion and correction
+for REQ-QML-012-AC01/AC02 and REQ-QML-020-AC02. This is a diagnostic gap; no data
+loss is inferred.
+
+### Canonical facade provenance — INC-QML-40
+
+The owning normalization now decides containment from cached physical identity
+before external fallback, preserving written/resolved endpoint key forms and
+strict native traversal rejection. A nearby real alias is tested independently
+of the older external helper's deep-path basename fallback. True external input
+and a foreign implementation cannot become in-root membership/native authority.
+
+| Acceptance ID | Exact automatically collected assignment | Local evidence and limits |
+| --- | --- | --- |
+| REQ-QML-020-AC01 | `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac01_nearby_alias_preserves_generic_cold_warm_provenance`; `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac01_nearby_alias_preserves_unchanged_native_smoke_and_mixed_graph`; `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac01_nearby_alias_preserves_real_decl_def_provenance` | Actual generic/Python/C++/QML source identity, CRLF bytes, canonical/alias cold/warm graphs and real declaration/definition carriers pass |
+| REQ-QML-020-AC02; REQ-QML-004-AC04 | `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac02_foreign_implementation_cannot_supply_an_in_root_definition`; `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac02_physical_external_source_keeps_external_policy_without_membership` | Explicit foreign input keeps external source policy/native rejection; no target membership or implicit discovery is added |
+| REQ-QML-020-AC03 | `tests/test_qt_facade_path_aliases.py::test_req_qml020_ac03_nearby_alias_updates_retire_membership_and_generic_targets`; `tests/test_qt_alias_policy_upgrade.py::test_req_qml020_ac03_policy18_no_change_upgrade_retains_repairs_and_repeats` | Real manual/watch generic then metadata-only edits, stale target/site retirement, canonical full parity, unrelated Python identity and mixed/C++-only no-edit policy upgrades pass; failed candidate stamp publication retains the cohort and repair/repeat completes |
+
+Original facade selection: four failed, one passed, five symlink skips.
+Mixed and C++-only policy-upgrade selections each fail two cases under policy 18.
+Final existing/new alias, policy and six unrelated source-portability/remap
+modules pass **65 cases, zero failures/errors, fourteen symlink-capability skips**,
+one existing Hypothesis warning, in 13.43 seconds. These selections overlap earlier
+proof and are not added to full-suite totals. Policy 19 refreshes prior native/Qt
+products at the same package/source version; AST schema 12 and persistence owners
+are unchanged. Generic-only prior API products require an explicit forced rebuild.
+The final 238/85-line test modules remain below 300; the facade's 8,997 lines stay
+within its existing 9,010 ceiling. Current hosted alias/platform proof remains open.
+
+### Failure-safe join diagnostics — INC-QML-41
+
+Diagnostic context no longer rethrows a persistent input identity failure.
+Guarded physical containment and lexical fallback are diagnostic-only; foreign,
+unavailable, overlong, control and Unicode line/paragraph-separator labels use
+an empty `source_file` string. Safe complete relative labels have a 160-character
+ceiling and are never truncated into another apparent identity. Every affected
+Qt/QML source retains the existing failure record; unrelated Python is untouched.
+
+| Acceptance ID | Exact automatically collected assignment | Local evidence and limits |
+| --- | --- | --- |
+| REQ-QML-012-AC01 | `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_all_sources_keep_safe_context_after_join_failure`; `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_real_alias_uses_canonical_or_explicit_unavailable_context`; `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_injected_context_delimiters_are_explicitly_unavailable`; `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac01_context_ceiling_preserves_or_rejects_complete_relative_label` | Ordinary, OSError and RuntimeError joins annotate every source with exact bounded QML_RESOLUTION_FAILED fields; canonical/real alias and 160/161/control boundaries pass |
+| REQ-QML-012-AC02; REQ-QML-020-AC02 | `tests/test_qt_pipeline_failure_context.py::test_req_qml012_ac02_persistent_failure_retains_products_and_repairs_without_leaking`; `tests/test_qt_pipeline_failure_context.py::test_req_qml020_ac02_foreign_transport_cannot_gain_authority_from_diagnostic_context` | Actual manual/watch QML_GRAPH_PRESERVED outcomes omit backend bodies, retain all four saved products, repair and repeat byte-for-byte; foreign transport/context cannot add authority |
+
+Focused RED: **nineteen failed, three passed, three symlink-capability skips**,
+4.90 seconds. Corrected focused profile: **22 passed, zero failures/errors, three
+capability skips**, 6.11 seconds. Existing failure/membership/update compatibility
+uses the guarded canonical Python 3.12.14 with all 197 locked distributions:
+**136 passed, zero failures/errors, ten symlink skips**, one existing Hypothesis
+warning, 115.19 seconds. These profiles overlap earlier selections and are not
+combined into a full-suite total. The changed helper covers seven of seven
+measured statements and two of two measured branches. Overall pipeline coverage
+is 89%; remaining uncovered collector/index-diagnostic paths are unchanged.
+The original coverage file is preserved; coverage evidence uses isolated storage.
+The production/test owners measure 106/190 lines, within the 300-line ceiling.
+
+### Final local alias artifact and contribution gates
+
+The combined frozen INC-QML-39/40/41 candidate is built from 671 source/test/tool
+Python files with canonical source-map SHA-256
+`795659a2a35b41b71443ad89e07ee739bef0242d67a0d82b0b41fb79f9ac822b`.
+Reviewed wheel SHA-256:
+`f2a759cc6ae5dd11d61a73806baebb867ebe4a1929cf06328b1d0473924a6799`.
+All 180 packaged Python payloads match source bytes; only membership, facade,
+derived policy and pipeline differ from the preceding core-05 wheel. Clean
+constrained extra/core environments use 40/30 compatible distributions from the
+unchanged 197-version source snapshot. Every loaded production module originates
+in that independent installation (128 extra/122 core modules).
+
+Unchanged offline `-I` QML/core smoke and the real Windows near-root temporary
+short-alias native smoke pass from a neutral working directory. Three ordinary
+reviewed-wheel metadata/import/parser-boundary tests also pass, with one existing
+Hypothesis warning. Artifact proof takes 9.32 seconds; Python is 3.12.14,
+tree-sitter 0.25.2, C++ grammar 0.23.4, language-pack 0.11.0 and watchdog 6.0.0.
+Source/dependency and original coverage bytes are preserved. This current artifact
+replaces the failed first alias candidate for its profile; hosted proof remains open.
+
+The same fresh optional artifact executes the three ordinary alias, policy-upgrade
+and failure-context modules through `python -I -X utf8`, importlib-mode pytest and
+an offline process/network guard from a neutral directory: **33 passed, zero
+failures/errors/warnings, ten actual symlink-capability skips**, 10.71 seconds.
+All 152 loaded Graphify modules originate in the artifact; only the test namespace
+is supplied by the source checkout. Its 180 payloads and 40 dependency versions,
+the source's 197 dependencies and original coverage bytes remain unchanged.
+The first harness attempt placed fixtures under the checkout's ignored `.venv`
+and records eight initial-build setup failures. Moving only its owned fixture
+directory to the host temporary area corrects setup without changing production,
+tests or assertions; both attempts are retained. Capability skips do not establish
+Linux/macOS acceptance and these overlapping selections are not added together.
+
+Ruff 0.15.14 passes the whole repository and all eight touched/new Python files;
+all 134 generated skill artifacts match. Actionlint 1.7.12 accepts both corrected
+workflows without suppressions. Document checks resolve every acceptance ID and
+exact test reference. AST-only graph maintenance is navigation, not acceptance.
+Targeted Pyright 1.1.409 on all eight files has 120 existing facade errors, exactly
+shared with immutable `135d50b`; the other seven files have zero diagnostics.
+Whole-project Pyright has **604 errors, zero warnings, 667 files**, exit one,
+20.990 seconds: 604 shared and zero added/removed under identical Python,
+dependencies and configuration. Typing remains a failed baseline gate.
+
+### Hosted Windows application selection — INC-CORE-07
+
+At source `f95366d`, the native compatibility job fails before pytest because
+Get-Command returns two Node applications and `.Source` projects both paths.
+The job retains dependency/import evidence only; identity, pytest exit, JUnit
+and post-test integrity are absent. No native test acceptance is inferred from
+that setup failure. Node and uv selection now retains one first-PATH application.
+
+| Acceptance ID | Exact ordinary regression | Evidence and limits |
+| --- | --- | --- |
+| REQ-CORE-004-AC02 | `tests/test_ci_windows_tools.py::test_req_core004_ac02_workflow_executes_first_real_path_application` | Actual workflow expressions execute a copied preferred real Node/uv binary before the original; scalar path and Node process.execPath prove exact executable identity |
+| REQ-CORE-004-AC03 | `tests/test_ci_windows_tools.py::test_req_core004_ac03_missing_workflow_application_has_no_fallback`; `tests/test_ci_windows_tools.py::test_req_core004_ac03_node_guard_rejects_real_incompatible_application` | Missing Node/uv and real uv named as Node reject through owning discovery/version expressions; no fake executable response or fallback |
+
+Immutable historical workflow: **two failures, three passes**, 7.22 seconds.
+Current workflow: **five passes, zero skips/failures/errors**, 13.38 seconds,
+native Windows PowerShell 5.1, Node 24.19.0 and actual uv. The focused test is
+109 physical lines; Ruff and whitespace checks pass. Original coverage bytes
+are retained. Corrected hosted execution and its complete evidence remain pending.
+
+### Native shell launch identity — INC-CORE-08
+
+Hosted `38bf8cc` records 783 native passes, 33 failures and 20 skips. Its retained
+artifact binds source/base/checkout, tools and unchanged 197 dependencies/source.
+Ten hook and 23 skill input-security cases launch the WSL stub through a bare
+Bash process name. A real direct probe confirms that PATH lookup selects Git
+Bash while native bare launch invokes another executable. Existing absolute
+preflight therefore cannot establish the ordinary Python consumer's identity.
+
+| Acceptance ID | Exact ordinary regression | Observable outcome |
+| --- | --- | --- |
+| REQ-CORE-004-AC02 | `tests/test_shell_portability_helpers.py::test_req_core004_ac02_admitted_shell_runs_real_posix_command` | Both selected absolute shells execute real POSIX syntax; no GNU-only assumption is imposed on POSIX |
+| REQ-CORE-004-AC02 | `tests/test_shell_portability_helpers.py::test_req_core004_ac02_native_cwd_shadow_requires_explicit_path_admission` | A real native CWD decoy shadows bare launch; configured-PATH selection excludes it until CWD is explicitly admitted |
+| REQ-CORE-004-AC02 | `tests/test_shell_portability_helpers.py::test_req_core004_ac02_real_consumers_resist_native_cwd_shadow` | Actual hook Bash/sh and skillgen launchers preserve the admitted process identity and original result assertions |
+| REQ-CORE-004-AC03 | `tests/test_shell_portability_helpers.py::test_req_core004_ac03_missing_shell_selection_is_rejected`; `tests/test_shell_portability_helpers.py::test_req_core004_ac03_invalid_shell_identity_is_rejected`; `tests/test_shell_portability_helpers.py::test_req_core004_ac03_unadmitted_shell_name_is_rejected` | Missing/invalid identity or unadmitted names reject before launch without global/system fallback |
+
+The unchanged original consumer functions fail three real decoy regressions;
+fifteen helper cases pass. Corrected ordinary shell/hook/skillgen selection
+passes **161 cases, eight explicit Windows/POSIX profile skips**, with all
+eighteen helper cases passing. Raw literal payload, hostile-input rejection,
+legitimate path, nonexistent path and vulnerable positive controls are retained.
+No subprocess-resolution adapter changes the process under test.
+
+The complete revised workflow consumer block executes before collection through
+the same helper: both actual Git shells prove the selected Python interpreter
+and Node 24.19.0 executable identity. Source (682 Python files), 197 dependencies
+and original coverage remain unchanged during this proof; the owned forwarding
+shim is removed. Shared helper/test files measure 56/161 lines, hooks retain
+1,600 and skillgen 222. Both workflows pass actionlint 1.7.12; all extracted
+Bash/Python blocks compile, and complete native blocks parse in Windows
+PowerShell 5.1 and pwsh 7.6.6. CI measures 467 within its documented 470 ceiling.
+These are local consumer/configuration results; corrected hosted proof remains
+assigned to the next normal PR event.
+
+### Native artifact report retention — INC-CORE-09
+
+Acceptance: REQ-CORE-004-AC03. Real source `f699b0f` native job
+`111592305005` passes **836 tests, 21 explicit platform/baseline skips**, zero
+failures/errors/warnings. JUnit individually passes all eighteen CORE-08 helper
+cases, the original 33 shell regressions, all seven CLI cases and five CORE-07
+selection cases. Artifact `11321839435` archive SHA-256
+`634462cf980eaa47090d2d64109702473d6142678ccb655bbb4a0f8db3a8f51f`
+matches GitHub's API digest. Source/base/synthetic checkout, tool versions,
+197 dependency identities, pytest exit and source integrity are retained.
+The real archive omits the generated `shell-consumers.json`; exact per-consumer
+identity transport therefore remains unavailable despite passing preflight.
+
+The one-line artifact inclusion correction leaves producer/test behavior and
+permissions unchanged. Its meaningful system regression is the actual GitHub
+archive read and report/profile identity verification described in
+[the acceptance procedure](../docs/COMPATIBILITY.md#native-consumer-evidence-retention--inc-core-09).
+This is a real external publication boundary, not a simulated or literal-only
+pytest test. Prepared configuration is not a retained-report pass. The corrected
+normal PR event must provide exact source/base/checkout and archive/record proof
+before AC03 is verified; current intended runs finish before publication.
+
+### Hosted source and optional-wheel checkpoint — f699b0f
+
+[CI 37255744156](https://github.com/SlinkyRamey/graphify/actions/runs/37255744156)
+and [QML 37255744181](https://github.com/SlinkyRamey/graphify/actions/runs/37255744181)
+are normal PR attempt-one runs. Source head
+`f699b0feb760ff0fc6347b3930cf79fa0b4a615f`, base
+`f61827f42378f6e7feff05a1cb4cbbcab1736ca8` and actual synthetic checkout
+`114824c6099ce6592f7fd025a291b129681bb445` are bound through API parent inspection,
+job logs and retained source identities. Both runs are terminal before the next
+correction is published; no duplicate dispatch or job retry is introduced.
+
+| Profile | Actual outcome | Evidence boundary |
+| --- | --- | --- |
+| Ubuntu Python 3.12/3.13/3.14 | Each 9,007 passed, 112 explicit skips, zero failures; end-to-end install passes | Full source suite, real disposable FalkorDB and reviewed source wheel |
+| Ubuntu Python 3.10 | 9,007 passed, 111 explicit skips, one cleanup-fixture failure; end-to-end install step omitted after failure | `test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence` does not inject its intended unlink failure on this interpreter |
+| Native Windows Python 3.12 | 836 passed, 21 explicit skips, zero failures/errors/warnings | JUnit/integrity/archive evidence above; consumer report omission remains INC-CORE-09 |
+| Optional QML wheel, four Windows lanes | Each 2,323 passed, three optional-extra skips | Platform source selection and unchanged isolated installed QML/core smokes |
+| Optional QML wheel, four macOS lanes | Each 2,271 passed, 55 Windows/optional-extra skips | Platform source selection and unchanged isolated installed QML/core smokes |
+| Optional QML wheel, four Ubuntu lanes | Each three artifact tests passed, zero skips | Unchanged isolated installed QML/core smokes also pass |
+
+All 39 assigned co-owner/remap/discovery/CLI source cases and the exact two live
+FalkorDB plus three wheel cases individually pass on every Ubuntu lane. All four
+downloaded archives match their API digests. Each 182-payload wheel matches the
+reviewed source Git blobs; checkout, tracked inputs, dependencies and wheel bytes
+remain unchanged. Both test graphs are deleted and all four owned containers are
+confirmed absent. The service has no mounted storage, Redis 8.10.2 and module
+60001; readiness includes actual graph-list/query operations. Source runner tools
+are Node 22.23.3, GNU cpp 13.3.0, Bash 5.2.21 and Git 2.55.0 on Ubuntu image
+20260927.320.1. Exact skip identities and per-case JUnit remain in retained source
+artifacts; an aggregate count does not turn a skip into acceptance.
+
+All twelve QML jobs pass, including all 24 installed smokes. Its platform suites
+run from the checkout and do not establish isolated installed origin for every
+case. That workflow retains no wheel-digest/JUnit artifact; logs supply selected
+module outcomes, not a fabricated per-case attestation. Windows/macOS Python 3.10
+each retain two existing warnings. Skill generation passes all five validators
+and 134 generated artifacts. Advisory security remains green by policy while
+Bandit reports four High/eight Medium/109 Low findings and completed pip-audit
+reports nine unique package/advisory identities across three packages. Their
+baseline origin is not assessed; this is not clean security acceptance. Local
+Pyright still fails with 604 exact baseline errors and zero added/removed errors.
+
+### Partial-setup cleanup fixture portability — INC-QML-48
+
+Acceptance: REQ-QML-012-AC02 and REQ-QML-018-AC06. Ordinary collected regression:
+`tests/test_publication.py::test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence`.
+The actual hosted Python 3.10 case fails before correction; native Python 3.10.21
+reproduces that failure against unchanged production/test bytes, while native
+Python 3.12.14 passes. Runtime inspection confirms the cached accessor bypass.
+
+The corrected scenario patches production's actual `Path.unlink` entry point,
+delegates real removal outside the exact rejected owned recovery target, and
+requires that target's observed fault. Existing `GRAPH_PUBLICATION_CLEANUP`, public
+message redaction, four accepted-product bytes and durable old-graph snapshot
+assertions remain intact. Production publication and diagnostics do not change;
+no resolver/schema/cache evidence is invalidated by this fixture correction.
+Corrected ordinary native execution uses:
+
+```text
+PYTHON -X utf8 -m pytest tests/test_publication.py tests/test_qt_product_publication.py tests/test_qt_readonly_publication.py tests/test_qt_publication_stub_origin.py -q -ra --tb=short -p no:cacheprovider --junitxml=REPORT.xml
+```
+
+Python 3.10.21/3.12.14/3.13.15/3.14.7 each pass **82 of 83 collected cases**, with
+one explicit actual file-symlink capability skip and zero failures/errors. Each
+includes all 32 publication cases and 51 adjacent Qt publication controls; the
+corrected cleanup case individually passes without a skip. Source production
+origin is verified. Dependency inventories (43/197/40/40 respectively), production
+and selected test bytes, original coverage and checkpoint HEAD remain unchanged
+through the run. A separate byte-exact `f699b0f` fixture replay reproduces the old
+3.10 failure against the same production boundary. Distinct retained red/fixed-stage
+logs bind each run to its actual test bytes.
+
+The final test has 297 physical lines and retains all four original assertion
+ASTs, adding exact owned-target and exception-cause checks. Targeted Ruff 0.15.14
+and Pyright 1.1.409 report zero diagnostics. Only this test file changes Python
+bytes from the prior exact 604-error baseline comparison; its baseline/current
+diagnostic sets are empty. Whole-project typing remains a failed baseline gate.
+Document checks resolve all 99 criteria and exact references; AST-only graph
+refresh completes. Production, dependency/wheel, error-catalog and architecture
+contracts are unchanged, so no new artifact payload or schema is introduced.
+Corrected four-lane hosted execution and retained-report transport pass at the
+verified checkpoint below. Local counts and skips alone do not establish them.
+
+### Verified hosted runner checkpoint — 10cb15a
+
+[CI 37257724886](https://github.com/SlinkyRamey/graphify/actions/runs/37257724886)
+and [QML 37257724896](https://github.com/SlinkyRamey/graphify/actions/runs/37257724896)
+are terminal successful normal PR attempt-one runs. Source
+`10cb15a028312f21a169760e5f059a8f4ddfbfba`, base
+`f61827f42378f6e7feff05a1cb4cbbcab1736ca8` and actual tested checkout
+`697437305a9f8959ed6022fad3c676b4714caa0e` match API parent inspection, all source
+identities and job logs. These results verify recorded profiles; they do not
+promise unexecuted device events, unrestricted Qt SDK semantics or later revisions.
+
+| Executed profile | Passed | Explicit skips | Failures/errors |
+| --- | --- | --- | --- |
+| Full Ubuntu Python 3.10.22 | 9,008 | 111 | 0 |
+| Full Ubuntu Python 3.12.3/3.13.16/3.14.8, each | 9,007 | 112 | 0 |
+| Native Windows Python 3.12 | 836 | 21 | 0 |
+| Four Windows QML source lanes, each | 2,323 | 3 | 0 |
+| Four macOS QML source lanes, each | 2,271 | 55 | 0 |
+| Four Ubuntu QML artifact lanes, each | 3 | 0 | 0 |
+
+All 40 assigned Linux cases (the prior 39 plus
+`tests/test_publication.py::test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence`)
+and the exact two live FalkorDB/three wheel cases are individually present and
+passed in every source lane. Each complete job also passes end-to-end installation.
+All four archive digests match the API. Every 182-payload wheel matches the tested
+Git blobs; source/configuration, dependencies and wheel bytes are retained.
+Dependency counts are 185/195/174/174 for Python 3.10/3.12/3.13/3.14. Both test
+graphs are deleted and every pinned job-owned service is confirmed absent, with
+zero mounts and verified image/readiness identity.
+
+Native artifact `11323730916` SHA-256
+`98e8645f9fd208b6502c62ea687fa83c81b48c6e190d9698ce62b85ffe1977b4`
+matches its API digest and contains the actual 412-byte `shell-consumers.json`.
+Both admitted Git Bash/sh executables identify the selected checkout Python and
+Node 24.19.0, agreeing with the real preflight's same-file checks and job/profile
+identity. All 18 helper, 33 former shell-failure, seven CLI and five tool-selection
+cases pass individually; 197 dependencies, source inputs and checkout stay
+unchanged. Retained JUnit, pytest exit, archive identity and upload completion
+close INC-CORE-09's evidence gap; a workflow literal is not that proof.
+
+All twelve optional-wheel jobs and all 24 isolated installed QML/core smokes pass.
+Its platform suites remain source profiles with logs rather than per-case JUnit,
+wheel-digest/source attestation or dependency-integrity artifacts. Skip contracts
+are unchanged: Windows has two optional MCP cases and one optional SVG case;
+macOS additionally has 28 Windows-readonly and 24 Windows-native-alias cases.
+Windows/macOS Python 3.10 each retain two existing warnings. Parser/dependency
+identities and grouped skips are retained in job logs; unavailable evidence is
+not converted into individual-case or isolated-origin claims.
+
+All five skill validators/134 generated artifacts pass. Advisory security
+continues to report four High/eight Medium/109 Low Bandit findings and nine unique
+pip-audit package/advisory identities across three packages; both scans exit one
+under the existing advisory policy. Local full typing retains 604 matched baseline
+errors with zero added/removed, while the changed test's diagnostic set is empty.
+These contribution/system limits remain distinct from the passing test profiles.
+
+Published test Git blob `66dec42c98e1d1f52d736e36cb369e3f5acf5000` has LF SHA-256
+`285a9561f3262e3f75f77e893132bfcc3fb59d5a00ea6ed019b2a2e75a795886`;
+the native tested file has SHA-256
+`27f0b19efc055039b1b71339432fb8c2d396de4aa8de9a440c6610cdd313953d`.
+Only two CRLF endings differ. All 682 tracked Python files match tested content
+exactly or by CRLF-to-LF normalization, with no unmatched file. Production Git
+payloads are unchanged from `f699b0f`. CI blob
+`66a508759924cb222adee7010156a092c2f2b37a` and QML-workflow blob
+`fc432d5a747483f1dcb10de88e768eda91ef1b44` bind the tested configuration.
+This completion edits documentation only; later review revisions retain their
+own normal PR validation and recorded SHA. No new reproduced defect appears in
+the exit review; INC-QML-49 remains unallocated and no merge/deployment occurs.
+
+### Manual discovery and native fixture contracts — INC-QML-46/47
+
+The `38bf8cc` hosted Linux older-policy cases fail because the fixture omits
+followed-directory discovery. The real manual parser separately rejects that
+option with exit 2. INC-QML-46 adds the explicit parser option and retains its
+default false, while every alias upgrade entry point uses the same supported
+profile. INC-QML-47 corrects only the short-parent parser-spy expectation under
+the already accepted INC-QML-45 long-spelling contract. Exact lexical symlink
+ownership and every existing cold/warm/native/ID/retention assertion remain.
+
+| Acceptance ID | Exact ordinary regression | Observable outcome |
+| --- | --- | --- |
+| REQ-QML-011-AC01/AC04 | `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac01_ac04_cli_default_and_optin_reach_real_publication` | Actual parser and nested rebuild receive false/true respectively and publish identical facts/products when corpus scope is unchanged |
+| REQ-QML-011-AC01 | `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac01_optin_cannot_discover_foreign_qt_provider`; `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac01_cli_help_exposes_supported_optin` | Real foreign alias cannot admit a native provider; actual help exposes the option and default |
+| REQ-QML-011-AC03 | `tests/test_qt_update_discovery_profile.py::test_req_qml011_ac03_invalid_cli_profile_refuses_before_rebuild_or_publication` | Four actual unknown-option/multiple-root orderings reject before rebuild and retain accepted graph/state/cache bytes |
+| REQ-QML-020-AC03; REQ-QML-011-AC03/AC04 | `tests/test_source_alias_provenance.py::test_req_qml020_ac03_policy19_products_refresh_retain_and_recover` | Real manual/watch opt-in refreshes the old-policy cohort; staged failure retains products/stamp, then repair/full parity/repeat succeeds |
+| REQ-QML-020-AC03 | `tests/test_source_alias_provenance.py::test_req_qml020_ac03_directory_symlink_requires_explicit_discovery_profile` | Real POSIX directory link remains excluded by default and appears only with explicit discovery; native Windows cannot supply this profile |
+| REQ-QML-020-AC01 | `tests/test_qt_project_membership_aliases.py::test_req_qml020_ac01_real_parent_alias_preserves_smoke_and_cold_warm_identity` | Exact native long-parent or lexical symlink parser inputs, warm unrelated cache, graph/IDs/membership parity and unchanged source bytes |
+
+Source command: environment Python `-X utf8 -m pytest` with the focused seven
+cohort/discovery modules, `-q --tb=short -p no:cacheprovider`, retained JUnit.
+It passes **70 cases, two actual file-symlink/POSIX-profile skips**; all seven
+new CLI cases and both upgrade operations pass. Broader CLI/watch compatibility
+passes **253 cases, five existing capability/profile skips**. The separate
+membership source module passes **nine cases, seven actual file-symlink skips**,
+with one existing Hypothesis collection warning. These overlapping profiles are
+not combined into a full-suite total.
+
+Fresh wheel SHA-256:
+`52a9c79c59ca2c1f985b3900655a6acf9f55b866b616e3d7f33d7767c17e449a`.
+All 182 Python payloads equal source and installation; only CLI/help differs
+from the preceding `c815eef8` wheel. Unchanged isolated QML/core and actual full-
+TEMP short-alias smoke pass. Twelve ordinary installed modules run through
+isolated `-I -X utf8` Python, `pytest --import-mode=importlib -p no:cacheprovider`,
+with a retained JUnit report and an offline audit guard. Only exact owned native
+junction fixture setup is admitted; analyzed code and network execution remain
+forbidden. Result: **118 passed, nineteen explicit skips, zero failures/errors/
+warnings**, 43.80 seconds pytest. Eighteen skips are actual file-symlink capability
+exclusions; one is the POSIX default-discovery profile. All 69 assigned new/prior
+co-owner/spelling/CLI/upgrade/native warm-input cases pass individually with no
+skip. All 154 loaded Graphify modules come from that artifact. Source (682 Python
+files), source 197/optional 40/core 30 dependency versions and original coverage
+are retained; owned fixtures are removed.
+
+Ruff 0.15.14 passes targeted/full source checks. Pyright 1.1.409/Python 3.12.14
+reports **604 errors, zero warnings, 678 files**. The fresh immutable `135d50b`
+baseline has 604 matching errors across 658 files: zero added or removed.
+Ten touched targets retain two exact existing errors, with zero new/removed.
+Typing remains a failed gate. Current CI consumer/configuration proof is recorded
+above; repository AST-only refresh completes with 22,641 nodes/51,218 edges and
+the existing deliberately malformed Luau fixture warning. No further local gap
+is reproduced; the corrected normal PR event owns hosted acceptance.
+
+### Discovered source identity — INC-QML-42
+
+Hosted f95366d fails the unchanged original tests
+`tests/test_cache.py::test_warm_cache_keeps_target_and_symlink_sources_distinct`
+and `tests/test_watch.py::test_rebuild_code_incremental_rename_preserves_symlink_source_path`
+in both completed Linux pytest profiles. The correction preserves their assertions;
+actual Linux replay remains assigned to the corrected hosted run. Local actual
+NTFS directory junctions reproduce six initial failures with one passing control
+and one unavailable file-symlink case; stronger dual-alias assertions also expose
+the shared prefix/stem lookup collapse and remain ordinary regressions.
+
+| Acceptance ID | Exact ordinary test | Source/installed checkpoint evidence |
+| --- | --- | --- |
+| REQ-QML-020-AC01 | `tests/test_source_alias_provenance.py::test_req_qml020_ac01_discovered_directory_sources_survive_cold_warm_and_reload`; `tests/test_source_alias_provenance.py::test_req_qml020_ac01_leaf_alias_retains_its_source_below_an_aliased_root`; `tests/test_source_alias_provenance.py::test_req_qml020_ac01_native_definition_keeps_discovered_directory_provenance` | Real target/directory aliases and aliased scan root retain exact disjoint IDs/call endpoints, warm cache fragments, directed/undirected JSON reload and C++ definition provenance; leaf symlink remains a host capability exclusion |
+| REQ-QML-020-AC02 | `tests/test_source_alias_provenance.py::test_req_qml020_ac02_foreign_alias_cannot_acquire_native_root_authority`; `tests/test_source_alias_provenance.py::test_req_qml020_ac02_failed_parent_anchor_keeps_only_accepted_physical_identity` | Foreign linked QObject remains excluded with exact QT_CPP_ROOT, unchanged source bytes and no native authority; failed lexical anchors retain only the accepted physical source |
+| REQ-QML-020-AC03 | `tests/test_source_alias_provenance.py::test_req_qml020_ac03_watch_rename_deletion_and_repeat_preserve_discovered_source`; `tests/test_source_alias_provenance.py::test_req_qml020_ac03_policy19_products_refresh_retain_and_recover` | Actual two renames/deletion/no-change graph bytes, no-edit policy19-to20 refresh, staged publication failure/cohort retention, repair/full parity and repeat pass |
+
+The full new selection passes **nine cases with one actual file-symlink skip**,
+3.89 seconds. Existing cache/watch/ID/barrel/native/callback and alias/update
+compatibility passes **405 cases with 31 capability skips**, 54.8 seconds.
+The pure helper covers fifteen of fifteen measured statements and four of four
+branches; isolated coverage storage preserves the original file. Facade/helper/
+policy/test physical sizes are 8,998/31/114/277, within existing ceilings. Policy
+20 refreshes older native/Qt products; AST schema 12 remains unchanged. Single-alias
+shared-content notification is separately reproduced and assigned INC-QML-44.
+
+### Nearby alias fixture — INC-QML-43
+
+REQ-QML-020-AC01 additionally maps to
+`tests/test_qt_nearby_alias_fixture.py::test_req_qml020_ac01_real_short_ancestors_preserve_nearby_facade_provenance`.
+The original real five-ancestor fixture records one setup error with ten ancestor
+hops; the corrected case passes with one real short-basename hop and unchanged
+production source/edge equality. Existing/new alias modules pass **eight cases,
+seven actual symlink capability skips**, one existing Hypothesis collection
+warning. The source files measure 241 and 59 physical lines. No production
+assertion, native SDK or graph/cache schema changes.
+
+### Installed discovered-alias checkpoint — INC-QML-42/43
+
+Wheel SHA-256:
+`65a096a0042b181582f179fd63c1108479822bac866f7fab0d3eaf5fb7730309`.
+All 181 source/installed Python payloads match; only facade, policy and new
+source_identity differ from the preceding f2 wheel. The isolated unchanged
+optional/core smoke and actual full-TEMP Windows short-alias smoke pass, with
+129/123 loaded artifact modules. The five-module installed contract selection
+passes **43 cases, eleven actual file-symlink capability skips, zero failures/
+errors/warnings**, 13.58 seconds. All 153 loaded Graphify modules originate in
+the artifact. The new source-identity module passes nine/one; the nearby-fixture
+case passes. These overlapping counts are not added to full-suite totals.
+
+Offline harness setup admits only ten exact seven-argument native cmd/mklink/J
+fixture operations within its verified owned temporary tree. Sixty other process
+attempts remain denied and no network call is admitted; owned junctions/temp data
+are confirmed removed. This exception establishes real fixture setup, not corpus
+execution authority. All 675 Python-file fingerprints, source197/optional40/core30
+dependency versions, HEAD3c70bfc and original coverage bytes remain unchanged.
+Separate source CI tool tests pass five cases without skips or warnings.
+
+Full Pyright remains **604 errors, zero warnings, 671 files**, exit one, 20.651
+seconds; every identity matches immutable135d50b under the same dependencies and
+configuration, with zero added/removed. Targeted seven-file Pyright retains only
+120 matched legacy-facade errors; the other six have zero diagnostics. Ruff passes.
+This checkpoint excludes the subsequent INC-QML-44/45 correction and does not
+establish corrected hosted or clean typing acceptance.
+
+### Shared physical co-owner watch invalidation — INC-QML-44
+
+Initial production probes reproduce stale facts after only one of two admitted
+source owners is notified. Real junction and hardlink regressions retain full/
+incremental equality assertions; the directory-rename case also exposes a shared
+absolute-ID remap collision. The extractor owner corrects that collision alongside
+INC-QML-45, without selecting source ownership through iteration order.
+
+| Acceptance ID | Exact ordinary production test | Assigned boundary |
+| --- | --- | --- |
+| REQ-QML-011-AC01/AC03; REQ-QML-020-AC03 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac01_ac03_qml020_ac03_one_owner_edit_refreshes_shared_content` | Actual generic/mixed directory-alias and hardlink edits; disjoint stable APIs, stale removal, unrelated facts, cold/warm/full/repeat |
+| REQ-QML-011-AC03; REQ-QML-012-AC03 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac03_normal_rename_and_deletion_retire_only_absent_owners` | Existing detection/deletion behavior, both owner kinds and full/incremental equality |
+| REQ-QML-012-AC01/AC02 | `tests/test_watch_physical_coowners.py::test_req_qml012_ac01_ac02_identity_failure_retains_recovers_and_repeats` | Five actual admitted resolve/stat/disappearance failures, safe diagnostic, prior four-product cohort and existing cache bytes, repair/repeat |
+| REQ-QML-012-AC04 | `tests/test_watch_physical_coowners.py::test_req_qml012_ac04_unsafe_or_long_missing_identity_context_is_empty`; `tests/test_watch_physical_coowners.py::test_req_qml012_ac04_context_quotes_delimiters_and_preserves_160_character_boundary` | Empty unsafe/unavailable context, JSON delimiter quoting and 160/161 limits |
+| REQ-QML-011-AC01 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac01_selection_cannot_discover_foreign_or_unadmitted_coowners`; `tests/test_watch_physical_coowners.py::test_req_qml011_ac01_missing_inode_identity_uses_contained_path_without_false_joins`; `tests/test_watch_coowner_admission.py::test_req_qml011_ac01_semantic_backed_hardlink_keeps_its_own_tier`; `tests/test_watch_coowner_admission.py::test_req_qml011_ac01_excluded_hardlink_cannot_reenter_the_corpus` | Foreign/unadmitted/nonregular rejection, conservative inode fallback, retained semantic tier and ignore exclusions |
+| REQ-QML-011-AC03/AC04; REQ-QML-020-AC03 | `tests/test_watch_physical_coowners.py::test_req_qml011_ac03_ac04_qml020_ac03_policy20_refresh_retains_repairs_and_repeats` | Actual unchanged policy20-to21 watch refresh, staged-stamp failure, retained cohort and repaired full/repeat parity |
+
+The new stateless helper measures 69 lines, facade watch 2,543 within its 2,550
+ceiling, and focused tests 280/70. Isolated helper coverage measures 39 of 39
+statements and ten of ten branches; original coverage is untouched. Combined
+source/installed results appear below; corrected hosted acceptance is pending. Early
+scan/stat bookkeeping remains outside the prior-product retention boundary;
+cache-byte assertions establish the actual rejection point rather than a universal
+promise about detection bookkeeping.
+
+### Native entry spelling and shared ID ownership — INC-QML-45
+
+Actual Windows short header/build-metadata spellings reproduce four membership/
+source-identity failures with one passing foreign-root control. Admission now
+precedes worker/cache work and verifies a native long spelling of the same file
+without resolving discovered directory owners. The remap correction separates
+primary walked claims from shared resolved forms; canonical supplied ownership,
+unique-alias fallback and ambiguous physical forms retain distinct contracts.
+
+| Acceptance ID | Exact ordinary production test | Assigned boundary |
+| --- | --- | --- |
+| REQ-QML-020-AC01 | `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac01_short_native_leaf_preserves_membership_facts_and_reload`; `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac01_native_utf16_lengths_preserve_astral_source_names` | Real header/build-metadata/both short inputs, exact raw facts, membership, original BOM/CRLF bytes/spans, UTF-16 supplementary names, cold/warm and directed reload |
+| REQ-QML-020-AC02 | `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac02_short_spelling_preserves_two_discovered_junction_owners`; `tests/test_qt_short_leaf_aliases.py::test_req_qml020_ac02_short_foreign_native_leaf_keeps_existing_root_rejection`; `tests/test_source_identity_remap_owners.py::test_req_qml020_ac02_primary_physical_owner_survives_both_batch_orders`; `tests/test_source_identity_remap_owners.py::test_req_qml020_ac02_multiple_aliases_cannot_authorize_arbitrary_physical_target` | Separate junction IDs/calls, foreign native rejection, canonical-first/last absolute/relative inputs and conservative ambiguous import ownership |
+| REQ-QML-012-AC01/AC02; REQ-QML-020-AC01/AC03 | `tests/test_source_input_admission.py::test_req_qml012_ac01_ac02_qml020_ac01_native_failure_retains_repairs_and_repeats`; `tests/test_source_input_admission.py::test_req_qml012_ac01_native_admission_refuses_before_any_worker_or_cache_write`; `tests/test_source_input_admission.py::test_req_qml012_ac01_native_filesystem_identity_failure_is_bounded` | Actual zero/oversize/empty/wrong-length/foreign-target/backend API failures, stat/same-file refusal, manual/watch prior products and cache bytes, repair/full parity/repeat, direct refusal before work |
+| REQ-QML-020-AC01 | `tests/test_source_input_admission.py::test_req_qml020_ac01_missing_and_nonfile_keep_existing_admission_shape`; `tests/test_source_input_admission.py::test_req_qml020_ac01_nonwindows_input_needs_no_native_lookup` | Existing missing/directory/non-file handling and no POSIX native API requirement |
+| REQ-QML-012-AC04 | `tests/test_source_input_admission.py::test_req_qml012_ac04_native_context_is_quoted_bounded_and_lexical`; `tests/test_source_input_admission.py::test_req_qml012_ac04_failed_context_lookup_keeps_bounded_admission_error` | Delimiters, empty/control/160–161 context, actual API failure plus failed lexical/CWD context adapter |
+
+An early ambiguous-control expectation excluded all builder stubs. The owning
+contract instead rejects newly admitted source/native authority and arbitrary
+alias selection; ordinary unresolved import stubs keep the upstream builder's
+existing behavior. Raw producer facts and built stub roles are verified separately.
+No graph-builder behavior or accepted-source policy is changed for that control.
+The combined local results are recorded below; corrected hosted proof is pending.
+
+### Combined source and installed checkpoint — INC-QML-44/45
+
+Native Windows/Python 3.12.14 source selection:
+**640 passed, 21 capability/profile skips, zero failures**, 114.30 seconds.
+All 59 new cases above execute without skips. The retained exclusions are eight
+cache file-symlink cases, two active-CWD removals, one Windows Git profile,
+one watch directory-symlink setup, one watch file-symlink, four extractor
+file-symlinks, one discovered leaf-symlink and three prior diagnostic-context
+symlinks. Actual Linux/platform proof is separately assigned to hosted execution.
+Focused new selections overlap this profile and are not added to its total.
+
+Fresh wheel SHA-256:
+`c815eef893aa928eb58287be2b56e41c22b99ba43999e2c3ec30026af82108ac`.
+All 182 source/wheel/installed Python payloads match, with only the approved five
+payload changes from the 65a096a checkpoint. Unchanged QML/core/full-TEMP short
+smokes pass; 129/123 loaded smoke modules come from the artifact. The ten-module
+installed profile passes **102 cases, eleven actual file-symlink skips, zero
+failures/errors/warnings**, 34.49 seconds; all 154 loaded Graphify modules
+originate in the artifact. All 59 new cases pass. Only 33 exact owned native
+junction-fixture callbacks are admitted; 157 other process attempts remain
+denied, with no additional process/network authority. Temporary junctions/data
+are confirmed removed. Source fingerprints for 681 Python files, 197/40/30 source/
+optional/core dependencies, basis HEADfca23e2 and original coverage stay unchanged.
+Separate actual Windows CI tool tests pass five cases, no skips/warnings.
+
+Source identity coverage: 76/76 statements and 22/22 branches; its broader control
+selection passes 54 cases with eight capability skips. Watch co-owner coverage:
+39/39 statements and 10/10 branches; all 21 ordinary cases pass. Both use isolated
+coverage storage. Whole-project Pyright 1.1.409 retains **604 errors, zero
+warnings, 677 files**, exactly matching immutable135d50b diagnostic identities
+under the same configuration/dependencies: zero added/removed. Targeted ten-file
+typing retains only 142 matched legacy facade/watch errors; other eight have zero.
+Ruff 0.15.14 and 134 generated artifacts pass. Actionlint 1.7.12 reports no
+workflow diagnostics; both complete native run blocks parse with zero errors in
+Windows PowerShell 5.1 and pwsh 7.6.6. Workflow size remains 450 lines.
+AST-only `graphify update . --no-cluster` completes with no LLM invocation;
+graph counts are navigation output rather than correctness evidence. Current
+hosted, typing and physical device/system gaps remain explicit.
+
+## Individual acceptance assignments
+
+The [follow-up audit](../docs/qt-qml/FOLLOWUP_AUDIT.md) supplies counterexamples for
+REQ-QML-008-AC01/AC03, REQ-QML-016-AC01–AC04 and REQ-QML-017-AC02/AC03/AC04. The table's historical
+passes do not cover those cases. Current INC-QML-17–20 correction evidence appears
+below; affected criteria retain explicit expanded-profile gaps.
+Exact opt-in probes and corrective increments appear in
+[scope corrections](#follow-up-audit-scope-corrections).
+
+Exact test references below identify executed cases. Documentation, privacy and
+hosted integration reviews are explicit review evidence rather than invented unit
+tests. Later revisions reverify affected evidence; preserve these identifiers.
+
+The INC-QML-06/07 rows retain their original bounded-profile evidence. Expanded
+shared-owner incremental contracts in REQ-QML-011-AC01/AC03/AC04 are assigned to
+INC-QML-44; new input-identity failure and short-leaf contracts are assigned to
+INC-QML-45. Historical passes do not verify those additions. Their current
+source, installed and hosted results are recorded separately below when run.
+
+| Acceptance ID | Actual evidence / assigned open case | Planned completion increment | Status |
+| --- | --- | --- | --- |
+| REQ-QML-001-AC01 | `tests/test_qml_wheel_artifact.py::test_qml001_ac01_built_wheel_contains_adapter_and_optional_extra_metadata`; `tests/test_qml_wheel_artifact.py::test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary` | INC-QML-01 | Verified (declared profile) |
+| REQ-QML-001-AC02 | `tests/test_qml_syntax_profile.py::test_qml001_ac02_handchecked_profile_uses_production_extractor_and_original_spans`; `tests/test_qml_syntax_profile.py::test_qml001_ac02_profile_runs_offline_in_fresh_production_process_without_corpus_execution` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-001-AC03 | `tests/test_qml_failures.py::test_qml001_ac03_missing_optional_import_is_safe_and_does_not_break_python`; `tests/test_qml_failures.py::test_qml001_ac03_incompatible_parser_load_is_bounded_failure` | INC-QML-01 | Verified (declared profile) |
+| REQ-QML-001-AC04 | `tests/test_qml_declarations.py::test_qml001_ac04_empty_source_is_distinguishable_from_parse_failure`; `tests/test_qml_declarations.py::test_qml012_ac01_malformed_or_partial_parse_has_no_authoritative_nodes`; `tests/test_qml_failures.py::test_qml001_ac04_unsupported_annotated_root_is_not_file_only_success` | INC-QML-01 | Verified (declared profile) |
+| REQ-QML-002-AC01 | `tests/test_qt_config_incremental.py::test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider; tests/test_qml_identity.py::test_qml01_ui_suffix_names_component_without_losing_filename_identity` | INC-QML-06 | Verified static source profile |
+| REQ-QML-002-AC02 | `tests/test_qt_metadata_admission.py::test_cmake_exact_name_does_not_reclassify_arbitrary_text; tests/test_qml_integration.py::test_qml002_ac02_named_qmldir_dispatch_directory_and_single_file_root` | INC-QML-06 | Verified static source profile |
+| REQ-QML-002-AC03 | `tests/test_qt_metadata_admission.py::test_qt_metadata_discovery_obeys_existing_ignore_policy; tests/test_qt_resource_resolution.py::test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected` | INC-QML-06 | Verified static source profile |
+| REQ-QML-002-AC04 | `tests/test_qml_identity.py::test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts; tests/test_qt_config_incremental.py::test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider` | INC-QML-06 | Verified static source profile |
+| REQ-QML-003-AC01 | `tests/test_qml_declarations.py::test_qml003_ac01_exact_declarations_ownership_and_raw_types`; `tests/test_qml_declarations.py::test_qml003_ac01_unicode_crlf_spans_and_original_names`; `tests/test_qml_syntax_profile.py::test_qml003_ac01_property_binding_and_array_objects_keep_exact_owners` | INC-QML-01 | Verified (declared profile) |
+| REQ-QML-003-AC02 | `tests/test_qml_identity.py::test_qml003_ac02_duplicate_basename_uses_full_relative_path`; `tests/test_qml_identity.py::test_qml003_ac02_inline_component_equal_ids_and_members_have_separate_owners`; `tests/test_qml_graph.py::test_qml003_ac02_same_stem_cpp_js_and_qml_do_not_merge`; template barrier cases in test_qml_syntax_profile.py | INC-QML-01 | Verified (declared profile) |
+| REQ-QML-003-AC03 | `tests/test_qml_declarations.py::test_qml003_ac03_comments_literals_groups_and_js_inner_functions_are_not_objects` | INC-QML-01 | Verified (declared profile) |
+| REQ-QML-003-AC04 | `tests/test_qml_identity.py::test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts`; `tests/test_qml_identity.py::test_qml003_ac04_comment_insert_changes_spans_but_not_named_identity`; `tests/test_qml_integration.py::test_qml010_ac01_real_process_pool_and_warm_order_match_sequential` | INC-QML-01 | Verified (declared profile) |
+| REQ-QML-004-AC01 | `tests/test_qml_resolution.py::test_aliased_directory_and_uri_imports_do_not_cross_bind`; `tests/test_qml_resolution.py::test_version_availability_and_latest_compatible_export` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-004-AC02 | `tests/test_qml_resolution.py::test_aliased_directory_and_uri_imports_do_not_cross_bind`; `tests/test_qml_resolution.py::test_versioned_layout_and_missing_version_evidence` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-004-AC03 | `tests/test_qml_resolution.py::test_competing_providers_and_missing_modules_have_no_target_edges` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-004-AC04 | `tests/test_qml_resolution.py::test_declared_roots_and_remote_import_never_expand_corpus`; `tests/test_qml_resolution.py::test_directory_and_script_projection_and_ignored_disk_provider` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-005-AC01 | `tests/test_qml_scope.py::test_component_ids_do_not_leak_between_files_or_inline_components` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-005-AC02 | `tests/test_qml_scope.py::test_inline_shadow_and_inherited_members_are_distinct_roles`; `tests/test_qml_scope.py::test_singleton_pragma_and_qualified_access` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-005-AC03 | `tests/test_qml_scope.py::test_internal_external_dynamic_and_lexical_members_stay_unresolved` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-005-AC04 | `tests/test_qml_resolution.py::test_module_script_exports_have_separate_lookup_roles` | INC-QML-02 | Verified (declared profile) |
+| REQ-QML-006-AC01 | `tests/test_qml_expressions.py::test_binding_reads_and_nested_qualified_aliases` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-006-AC02 | `tests/test_qml_expressions.py::test_alias_cycles_missing_and_ambiguous_targets_never_guess`; `tests/test_qml_expressions.py::test_component_and_object_scope_do_not_bind_hidden_identifiers` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-006-AC03 | `tests/test_qml_expressions.py::test_dynamic_reads_and_executable_source_are_only_analyzed` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-006-AC04 | `tests/test_qml_expressions.py::test_repeated_sites_survive_actual_directed_build_and_json`; `tests/test_qml_integration.py::test_qml010_ac02_default_undirected_export_reload_keeps_qml_direction` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-007-AC01 | `tests/test_qml_handlers.py::test_parameters_block_bindings_nested_functions_and_computed_calls`; `tests/test_qml_handlers.py::test_inherited_and_alias_target_signal_parameters_shadow_properties` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-007-AC02 | `tests/test_qml_scripts.py::test_literal_imported_helpers_and_library_do_not_inherit_document_ids`; `tests/test_qml_scripts.py::test_mjs_explicit_exports_aliases_and_private_functions`; `tests/test_qml_scripts.py::test_accepted_script_dependencies_support_classic_and_esm_imports`; `tests/test_qml_scripts.py::test_script_overlay_retains_original_shared_js_nodes_and_never_executes` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-007-AC03 | `tests/test_qml_handlers.py::test_declared_and_property_change_handlers_are_subscriptions`; `tests/test_qml_handlers.py::test_connections_target_and_dynamic_target_stay_distinct`; `tests/test_qml_adversarial.py::test_mixed_legacy_connections_handlers_do_not_activate_ignored_function_handlers` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-007-AC04 | `tests/test_qml_scripts.py::test_generic_js_calls_cannot_bind_to_qml_owned_expression_sites`; `tests/test_qml_scripts.py::test_script_overlay_does_not_read_unaccepted_imports_or_network` | INC-QML-03 | Verified (declared profile) |
+| REQ-QML-008-AC01 | `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints` | INC-QML-05 | Locally verified bounded alias registration correction; adoption gaps retained |
+| REQ-QML-008-AC02 | `tests/test_qt_cpp_exposure.py::test_header_implementation_members_reuse_accepted_canonical_ids`; `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints`; `tests/test_qt_cpp_definition_ownership.py::test_req_qml008_ac02_definition_provenance_keeps_emission_owned_through_aggregate`; normalization, conflicting callable/body, missing completeness, external/local class, generic identity and original-byte controls in that module; `tests/test_qt_cpp_owner_upgrade.py::test_qml008_ac02_policy_three_reparses_unchanged_native_ownership` (update/extract); [current constructor/source-containment assignments](#constructor-and-source-containment-corrections) | INC-QML-05; INC-QML-10/12/13 corrections | Locally verified bounded ownership; qualified ID correction has separate INC-QML-21 evidence below |
+| REQ-QML-008-AC03 | `tests/test_qt_native_project_integration.py::test_cpp_source_in_two_distinct_build_contexts_has_no_arbitrary_native_provider; tests/test_qt_qml_integration.py::test_ambiguous_overload_and_version_revised_member_are_explicit` | INC-QML-05 | Locally verified bounded alias rejection; broader adoption/overload gaps retained |
+| REQ-QML-008-AC04 | `tests/test_qt_cpp_syntax.py::test_unicode_crlf_macro_spans_are_original_bytes; tests/test_qt_cpp_syntax.py::test_comments_strings_raw_literals_and_preprocessor_definitions_are_inert` | INC-QML-05 | Verified static source profile |
+| REQ-QML-009-AC01 | `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints; tests/test_qt_resource_resolution.py::test_public_qt6_cmake_and_qmake_fixtures_describe_identical_membership` | INC-QML-05 | Verified static source profile |
+| REQ-QML-009-AC02 | `tests/test_qml_types_metadata.py::test_qmltypes_members_flags_exports_original_byte_provenance; tests/test_qml_types_metadata.py::test_qmltypes_cpp_member_conflict_preserves_authoritative_source` | INC-QML-05 | Verified static source profile |
+| REQ-QML-009-AC03 | `tests/test_qt_project_admission.py::test_qml009_ac03_public_metadata_qrc_load_build_export_reload; tests/test_qt_resource_resolution.py::test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected` | INC-QML-05 | Verified static source profile |
+| REQ-QML-009-AC04 | `tests/test_qt_project_metadata.py::test_cmake_conditional_and_expanded_metadata_is_not_authoritative; tests/test_qt_project_metadata.py::test_qmake_conditions_expansion_functions_never_produce_guessed_context` | INC-QML-05 | Verified static source profile |
+| REQ-QML-010-AC01 | `tests/test_qt_worker_cache_integrity.py::test_qml010_ac01_actual_workers_preserve_configured_roots_script_and_native_facts`; `tests/test_qml_identity.py::test_qml010_ac01_filename_normalization_collisions_keep_distinct_ids`; `tests/test_qml_identity.py::test_qml003_ac02_unicode_normalization_colliding_members_remain_distinct`; `tests/test_qml_integration.py::test_qml010_ac01_real_process_pool_and_warm_order_match_sequential` | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-010-AC02 | `tests/test_qt_qml_export_consumers.py::test_json_repeated_native_mechanisms_remain_distinct_and_directional`; `tests/test_qt_qml_export_consumers.py::test_native_path_reports_qml_call_site_and_cpp_declaration_without_reversing_flow`; `tests/test_qt_graph_persistence.py` | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-010-AC03 | `tests/test_qt_qml_export_consumers.py::test_json_repeated_native_mechanisms_remain_distinct_and_directional`; `tests/test_qml_expressions.py::test_repeated_sites_survive_actual_directed_build_and_json`; `tests/test_qt_export_matrix.py::test_cypher_keeps_parallel_mechanisms_and_escapes_literal_data` | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-010-AC04 | `tests/test_qt_affected_definitions.py::test_qml016_ac04_signal_change_reports_canonical_out_of_line_function_owner`; `tests/test_qt_signals_slots.py::test_native_events_have_distinct_sites_and_no_delivery_calls`; `tests/test_qt_graph_persistence.py`; `tests/test_build.py::test_build_merge_sln_stub_does_not_replace_referenced_csproj`; `tests/test_build.py::test_build_merge_project_reference_stub_does_not_replace_referenced_project`; `tests/test_build.py::test_merge_raw_extraction_cross_file_stub_parity` | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-011-AC01 | `tests/test_qt_final_incremental_parity.py::test_qml011_ac01_real_normal_qml_edit_matches_cold_warm_manual_and_watch`; `tests/test_qt_metadata_incremental.py::test_native_cpp_provider_only_edit_refreshes_unchanged_qml_and_warm_overlay` | INC-QML-06 | Verified (bounded static profile) |
+| REQ-QML-011-AC02 | `tests/test_qt_metadata_incremental.py::test_build_provider_only_mutations_equal_clean_accepted_corpus; tests/test_qt_metadata_incremental.py::test_resource_only_mutations_refresh_unchanged_cpp_loaders; tests/test_qt_config_incremental.py::test_qml011_ac02_last_qt_source_deletion_cleans_facts_and_commits_nonqt_state` | INC-QML-06 | Verified static source profile |
+| REQ-QML-011-AC03 | `tests/test_qt_config_incremental.py::test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider; tests/test_qt_config_incremental.py::test_qml011_ac03_package_version_change_reanalyzes_unchanged_corpus` | INC-QML-06 | Verified static source profile |
+| REQ-QML-011-AC04 | `tests/test_qt_final_incremental_parity.py::test_qml011_ac04_real_no_change_updates_preserve_every_fact_and_unrelated_python`; `tests/test_qt_metadata_incremental.py::test_build_provider_only_mutations_equal_clean_accepted_corpus`; `tests/test_qt_metadata_incremental.py::test_resource_only_mutations_refresh_unchanged_cpp_loaders` | INC-QML-06 | Verified (bounded static profile) |
+| REQ-QML-012-AC01 | `tests/test_qml_failures.py::test_qml001_ac03_missing_optional_import_is_safe_and_does_not_break_python`; `tests/test_qml_failures.py::test_qml012_ac01_native_parse_exception_is_explicit_safe_failure`; `tests/test_qml_resolver_safety.py::test_native_join_failure_is_guarded_and_successful_retry_is_clean` | INC-QML-06 | Verified (bounded static profile) |
+| REQ-QML-012-AC02 | `tests/test_qt_worker_cache_integrity.py::test_qml012_ac02_failed_native_parser_keeps_prior_real_ast_cache_and_products`; `tests/test_qt_config_incremental.py::test_qml012_ac02_malformed_new_source_preserves_graph_manifest_and_stamp`; `tests/test_qt_metadata_incremental.py::test_failed_metadata_update_preserves_prior_graph_manifest_and_report` | INC-QML-06 | Verified (bounded static profile) |
+| REQ-QML-012-AC03 | `tests/test_qt_config_incremental.py::test_qml011_ac02_last_qt_source_deletion_cleans_facts_and_commits_nonqt_state`; `tests/test_qml_watch_persistence.py::test_watch_qml_failure_preserves_completed_products_with_force`; `tests/test_qml_cli_persistence.py` | INC-QML-06 | Verified (bounded static profile) |
+| REQ-QML-012-AC04 | `tests/test_qml_failures.py::test_qml012_ac04_deep_source_terminates_at_supported_bound`; `tests/test_qml_failures.py::test_qml012_ac04_explicit_root_rejection_does_not_expose_absolute_paths`; `tests/test_qt_resource_resolution.py::test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected`; `tests/test_qt_qml_search.py::test_deep_malformed_literal_transport_cannot_crash_production_search` | INC-QML-06 | Verified (bounded static profile) |
+| REQ-QML-013-AC01 | `tests/test_qt_query_consumers.py::test_cli_query_explain_and_path_use_source_scoped_nodes`; `tests/test_qt_qml_export_consumers.py::test_native_path_reports_qml_call_site_and_cpp_declaration_without_reversing_flow`; `tests/test_qt_mcp_consumers.py::test_mcp_exact_id_path_matches_cli_and_retains_source_evidence` | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-013-AC02 | `tests/test_qt_affected_consumers.py::test_property_change_reports_binding_and_owning_component`; `tests/test_qt_affected_definitions.py::test_qml016_ac04_signal_change_reports_canonical_out_of_line_function_owner`; `tests/test_qt_affected_consumers.py::test_future_metadata_or_foreign_file_ownership_is_not_dependency_evidence` | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-013-AC03 | `tests/test_qt_qml_export_consumers.py`; `tests/test_qt_export_matrix.py`; `tests/test_qt_graph_html_payload.py`; `tests/test_qt_html_consumers.py`; `tests/test_qt_source_coverage_report.py`; format-specific omissions and unexecuted live database behavior: [EXPORT_MATRIX](../docs/qt-qml/EXPORT_MATRIX.md) | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-013-AC04 | `tests/test_qt_mcp_consumers.py`; `tests/test_qt_mcp_stdio.py`; `tests/test_qt_qml_search.py` | INC-QML-07 | Verified (bounded static profile) |
+| REQ-QML-014-AC01 | `tests/test_qml_wheel_artifact.py`; `tests/test_qml_platform_matrix.py`; `tests/qml_installed_smoke.py`; twelve exact-head optional/core wheel jobs: [PLATFORM_MATRIX](../docs/qt-qml/PLATFORM_MATRIX.md) | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-014-AC02 | `tests/test_qml_identity.py::test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts`; `tests/test_qt_cpp_syntax.py::test_unicode_crlf_macro_spans_are_original_bytes`; `tests/test_qml_wheel_artifact.py::test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary` | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-014-AC03 | `tests/test_extract.py`; `tests/test_cache.py`; `tests/test_serve.py`; `tests/test_export.py`; `tests/test_affected_cli.py`; `tests/test_callflow_html.py`; affected baseline full suites in four hosted Ubuntu Python lanes; local optional skips and Windows baseline defects remain in VALIDATION.md | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-014-AC04 | Executed skip/baseline/typing accounting in [VALIDATION](../docs/qt-qml/VALIDATION.md); optional MCP executed through actual HTTP/stdio; absent SVG is an explicit omission | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-015-AC01 | Requirement/68-criterion/link/status review and reviewable stacked PRs; [IMPLEMENTATION](../docs/qt-qml/IMPLEMENTATION.md), [PLAN](../docs/qt-qml/PLAN.md) | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-015-AC02 | `tests/test_qml_skillgen_guidance.py`; `tests/test_skillgen.py`; five generator validators run separately; all 134 generated artifacts and expected outputs regenerated | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-015-AC03 | Verified upstream v8/base/head and earlier upstream proposal read-only; preserved grammar attribution; exact PR head/base/tested merge and workflows recorded in [VALIDATION](../docs/qt-qml/VALIDATION.md) | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-015-AC04 | Executed public privacy/reference/footprint review; all 68 criteria accounted for with explicit static/runtime/platform/export boundaries; [README](../docs/qt-qml/README.md), [EXPORT_MATRIX](../docs/qt-qml/EXPORT_MATRIX.md), [PLATFORM_MATRIX](../docs/qt-qml/PLATFORM_MATRIX.md) | INC-QML-07 | Verified (declared hosted/static profile) |
+| REQ-QML-016-AC01 | `tests/test_qt_cpp_syntax.py::test_access_sections_keep_original_roles_offsets`; `tests/test_qt_signals_slots.py::test_native_events_have_distinct_sites_and_no_delivery_calls`; `tests/test_qt_signals_slots.py::test_macro_sections_private_meta_slots_and_comments`; `tests/test_qt_cpp_definition_ownership.py::test_req_qml016_ac01_forward_declaration_does_not_compete_with_complete_definition` | INC-QML-04b; INC-QML-10 correction | Partial; alias correction locally verified; inherited/qualified identity gaps INC-QML-11/21 |
+| REQ-QML-016-AC02 | `tests/test_qt_signals_slots.py::test_overloads_need_selector_and_dynamic_sender_stays_unresolved`; `tests/test_qt_events_boundaries.py::test_functor_function_and_connection_handle_disconnect`; `tests/test_qt_events_boundaries.py::test_explicit_cast_signal_to_signal_and_condition_flags`; `tests/test_qt_events_boundaries.py::test_private_typed_pointer_and_incompatible_receiver_are_rejected`; [alias counterexamples](#follow-up-audit-scope-corrections) | INC-QML-04b; INC-QML-19 correction | Locally verified bounded aliases; generic overload gap INC-QML-15 retained |
+| REQ-QML-016-AC03 | `tests/test_qt_events_boundaries.py::test_explicit_cast_signal_to_signal_and_condition_flags`; `tests/test_qt_events_boundaries.py::test_computed_signal_receiver_and_custom_connect_are_not_qt_targets`; `tests/test_qt_signals_slots.py::test_native_events_have_distinct_sites_and_no_delivery_calls`; [alias counterexamples](#follow-up-audit-scope-corrections) | INC-QML-04b; INC-QML-19 correction | Locally verified bounded alias/conditional rejection; unsupported forms retained |
+| REQ-QML-016-AC04 | `tests/test_qt_event_incremental_consumers.py::test_qml016_ac04_event_mutation_and_removal_match_clean_rebuild_without_delivery_calls`; `tests/test_qt_html_consumers.py::test_signal_emission_is_not_rendered_as_a_caller_in_the_call_table`; `tests/test_qt_affected_definitions.py`; `tests/test_qt_mcp_consumers.py::test_qml016_ac04_http_metadata_search_keeps_connection_reference_semantics`; `tests/test_qt_worker_cache_integrity.py`; `tests/test_qt_cpp_owner_upgrade.py::test_qml016_ac04_failed_owner_upgrade_retains_prior_graph_stamp_and_manifest` (force off/on); persisted JSON and aggregate ownership in `tests/test_qt_cpp_definition_ownership.py` | INC-QML-07; INC-QML-10 correction | Partial; alias lifecycle/artifact evidence passes; inherited/qualified identity gaps retained |
+| REQ-QML-017-AC01 | `tests/test_qt_project_admission.py::test_qml017_ac01_literal_module_load_reaches_declared_component_and_property; tests/test_qt_project_admission.py::test_qml009_ac03_public_metadata_qrc_load_build_export_reload` | INC-QML-05 | Locally verified bounded literal loader correction; omitted API families excluded |
+| REQ-QML-017-AC02 | `tests/test_qml_cpp_access.py::test_view_root_and_literal_object_name_property_access`; `tests/test_qt_access_providers.py::test_qqmlproperty_read_write_preserves_property_handle`; `tests/test_qt_project_admission.py::test_qml017_ac01_literal_module_load_reaches_declared_component_and_property`; `tests/test_qt_cpp_definition_ownership.py::test_req_qml017_ac02_namespace_definition_owns_source_backed_qml_access`; [receiver counterexamples](#follow-up-audit-scope-corrections) | INC-QML-05; INC-QML-10/17 corrections | Partial; receiver correction passes; static reflection correction has separate INC-QML-22 evidence below |
+| REQ-QML-017-AC03 | `tests/test_qt_access_providers.py`; `tests/test_qt_project_admission.py::test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints`; `tests/test_qt_native_project_integration.py`; [engine counterexample](#follow-up-audit-scope-corrections) | INC-QML-05; INC-QML-18 correction | Locally verified exact declaration/provider and component-engine profile; factory/member gap retained |
+| REQ-QML-017-AC04 | `tests/test_qt_final_incremental_parity.py::test_qml017_ac04_qml_member_edit_refreshes_unchanged_reverse_cpp_access`; `tests/test_qml_cpp_access.py::test_duplicate_object_names_do_not_select_first_child`; `tests/test_qt_access_providers.py::test_duplicate_context_provider_and_local_shadow_do_not_choose`; `tests/test_qt_metadata_incremental.py`; `tests/test_qt_qml_export_consumers.py`; `tests/test_qt_mcp_stdio.py`; persisted export ownership in `tests/test_qt_cpp_definition_ownership.py::test_req_qml017_ac02_namespace_definition_owns_source_backed_qml_access`; [audit counterexamples](#follow-up-audit-scope-corrections) | INC-QML-07; INC-QML-10/17/18 corrections | Partial; bounded receiver/provider/loader lifecycle passes; INC-QML-21/22 have separate evidence below; wider system gaps remain |
+
+## Native ownership correction and remaining gaps
+
+The baseline follow-up audit A13 failed REQ-QML-008-AC01/AC03 and REQ-QML-016-AC01/AC04
+for lexical aliases despite the earlier bounded passes in the individual table.
+These statuses are partial/failed for the new cases. INC-QML-19 owns their
+registration/emission/connection correction; INC-QML-11 remains inherited lookup.
+
+INC-QML-10's bounded source-ownership correction is locally verified through the
+production facade, graph assembly, JSON publication/reload, aggregate HTML and
+real CLI upgrade/retention paths. The final focused ownership/context/upgrade
+selection passes 88 cases in 7.38 seconds. The reviewed final-wheel Qt/QML, C++,
+HTML and export selection passes 954 cases with seven documented skips. Exact
+commands, artifact identity and red/green evidence belong to
+[validation](../docs/qt-qml/VALIDATION.md#inc-qml-10-native-source-ownership).
+These results do not establish browser appearance or new hosted/platform evidence.
+
+Accepted unchanged-header contexts have separate production regressions in
+`tests/test_qt_cpp_context_ownership.py::test_req_qml008_ac02_borrowed_complete_header_is_not_counted_as_two_definitions`
+and `tests/test_qt_cpp_context_ownership.py::test_req_qml016_ac01_context_pipeline_preserves_owned_emission_through_json_reload`.
+They map REQ-QML-008-AC02 and REQ-QML-016-AC01/AC04 to actual collector and
+pipeline/join/build/publication/reload ownership, canonical IDs and unchanged
+borrowed dictionaries. Genuine distinct-body and false/missing completeness
+controls in the same module retain unproved endpoints; equivalent relative,
+Windows-separator and absolute path spellings retain one body identity. These
+cases first failed before the borrowed source/span transport correction and pass
+within the final focused selection.
+
+INC-QML-11 now corrects grandparent inherited-signal endpoint lookup under
+REQ-QML-016-AC01/AC04 with the exact source/update evidence below. Earlier
+immediate-base-only evidence is superseded for this bounded lookup profile. INC-QML-12/13 now have bounded source
+implementation and regression evidence below. Missing/unaccepted class bodies,
+collapsed overloads and dynamic targets retain explicit unavailable ownership.
+A proven enclosing callable or accepted source file may contain the occurrence
+without establishing its native class, QObject role or semantic target. These
+corrections do not use containment as proof of a missing class or runtime call.
+
+## Constructor and source-containment corrections
+
+INC-QML-12 owns generic constructor declaration/definition proof and canonical
+parent correction. INC-QML-13 owns native source-site/file containment, upgrade
+invalidation and truthful community counts. Final reviewed policy-5/schema-7
+wheel identity, complete suite and installed public source-file proof are in
+[validation](../docs/qt-qml/VALIDATION.md#inc-qml-1213-current-source-and-view-evidence).
+The earlier 1029-pass artifact used policy 4 and does not verify the final
+source-file change. Each row retains its original criterion identity.
+
+| Affected criterion | Exact production regression evidence | Boundary and current state |
+| --- | --- | --- |
+| REQ-QML-008-AC02 | `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_constructor_prototypes_are_callable_methods`; `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_constructor_definition_retains_id_and_exact_accepted_owner`; `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_constructor_join_rejects_foreign_or_corrupt_accepted_proof`; `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_corrupted_owner_cannot_relabel_another_actual_constructor`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_plain_member_uses_exact_callable_without_native_class_claim`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_local_class_links_to_enclosing_callable_without_type_authority`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_source_fallback_rejects_unproved_or_noncallable_context`; `tests/test_qt_source_file_containment.py::test_req_qml008_ac02_unknown_native_owner_keeps_actual_file_context_after_reload`; `tests/test_qt_source_file_containment.py::test_req_qml008_ac02_file_context_requires_unique_actual_file_role` | Locally verified bounded ownership; qualified ID correction has separate INC-QML-21 evidence below |
+| REQ-QML-010-AC02/AC04 | `tests/test_cpp_constructor_ownership.py::test_req_qml008_ac02_generic_constructor_facts_keep_original_bom_crlf_unicode_spans`; `tests/test_qt_constructor_ownership.py::test_req_qml008_ac02_constructor_spans_use_original_bom_crlf_unicode_bytes`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_source_link_survives_build_json_and_aggregate`; `tests/test_qt_member_source_links.py::test_req_qml008_ac02_direct_collector_borrows_exact_callable_without_mutating_context`; file containment/reload and role-rejection cases above | Original bytes, canonical IDs, EXTRACTED containment direction, immutable borrowed dictionaries and unresolved native status remain separate from inferred target resolution. Current artifact/reload proof passes. |
+| REQ-QML-011-AC03 | `tests/test_qt_source_links_upgrade.py::test_req_qml011_ac03_source_links_upgrade_reparses_unchanged_cpp` (extract/update; prior policy/schema 3/6 and 4/7) | Real CLI reparses unchanged accepted C++ at the same package version, retires incompatible AST entries, preserves unrelated Python and matches a clean rebuild; repeated operation is idempotent. Current policy 5/schema 7 source/upgrade and installed public-fixture evidence pass. |
+| REQ-QML-011-AC04 | `tests/test_qt_source_links_upgrade.py::test_req_qml011_ac04_removed_admission_removes_source_overlay`; `tests/test_qt_source_file_containment.py::test_req_qml011_ac04_unowned_file_sites_refresh_and_remove_stale_links`; `tests/test_qt_constructor_ownership.py::test_req_qml016_ac04_and_qml017_ac04_constructor_updates_remove_stale_sites_and_preserve_failures` | Actual cold/warm, manual update and watch, edit/removal, no-change repeat and unrelated facts. Supported constructor/member cases require full normalized parity; exact generic overload identity remains unproved and is not passed by native source-file comparison. |
+| REQ-QML-012-AC02 | `tests/test_qt_source_links_upgrade.py::test_req_qml012_ac02_failed_source_links_upgrade_retains_products` (force off/on); constructor mutation/retention case above (manual/watch) | Real malformed-source rejection retains graph, manifest, analysis stamp and applicable root marker; corrected retry and repeat succeed. These cases supplement earlier actual cache/publication-failure regressions, rather than proving every persistence failure by one fixture. Source and current installed public-fixture retention pass. |
+| REQ-QML-016-AC01/AC04 | `tests/test_qt_constructor_ownership.py::test_req_qml016_ac01_and_qml017_ac02_constructor_owns_emission_and_write_after_json_reload` (namespace/plain; directed/undirected); `tests/test_qt_constructor_ownership.py::test_req_qml016_ac04_constructor_conflicts_cannot_invent_a_canonical_owner`; constructor mutation/retention case above | Canonical constructor/source ownership through assembly, JSON reload, query and affected; no fabricated delivery call. Duplicate bodies, foreign namespaces and collapsed overloaded delegation retain unavailable native target proof. Inherited endpoint gap INC-QML-11 remains unverified. |
+| REQ-QML-017-AC02/AC04 | The same exact constructor emission/write, conflict and mutation/retention tests | Literal accepted resource handles independently authorize QML access in a proven source callable; native class authority is not guessed from that access. Current reviewed source/wheel/public-fixture proof passes; unsupported dynamic handles/provider adoption remain separate gaps. |
+| REQ-QML-019-AC02 | [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_internal_only_group_has_source_edges_despite_zero_neighbors`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_external_source_edges_are_distinct_from_neighbor_count`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_isolated_source_member_does_not_claim_internal_connectivity`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_source_edge_counts_keep_direction_parallel_edges_and_self_loops`); [frozen test_html_community_links owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_links.py) (`test_req_qml019_ac02_invalid_preaggregated_source_counts_remain_unavailable`); supplied-meta/small-view controls in that module | Historical complete-fork evidence; deferred for this submission. Eleven emitted-script/exporter cases pass. Counts use canonical graph edges, preserve source inputs and do not add fake plotted loops; unavailable counts, escaping and ordinary small-view Degree remain explicit. No new browser visual run is claimed. |
+
+The source-site suite first failed seven cases before correction. The actual
+facade source-file case then failed until the pipeline supplied explicit fresh
+AST IDs; absent origin on borrowed nodes remains insufficient. The current
+focused containment selection passes 63 cases in 9.56 seconds. Final six-module
+source proof passes 90 cases in 14.70 seconds; final source-file/upgrade proof
+passes 20 cases in 6.06 seconds. The final reviewed artifact broad suite passes
+1044 cases with seven skips and one existing warning in 137.43 seconds. All 154
+Python payloads match reviewed source, wheel and installation. The installed
+public fixture rejects damaged source with force/partial options while preserving
+four prior products, then repairs and repeats successfully. The HTML link
+suite first failed six cases, then passes eleven; its related exporter/CLI
+selection passes 172 cases. Commands and revision boundaries are in validation.
+
+## Planned adoption criteria
+
+Owner: Qt/QML integration maintainer. The three work packages are implemented;
+their individual local source and installed acceptance is recorded below. This
+heading retains its earlier external anchor. AC07 is a separate header discovery
+contract; it does not borrow verification from the native syntax criterion.
+The [INC-QML-08 plan](../docs/qt-qml/PLAN.md#inc-qml-08--installed-project-adoption-hardening)
+records scope and exit checks. Original REQ-QML-001–017 hosted evidence applies to
+its original bounded profile only. Every row here is locally verified for Windows
+x64/Python 3.12; current hosted lanes remain unexecuted.
+
+| Criterion | Evidence or exact remaining gap | Completion increment | Status |
+| --- | --- | --- | --- |
+| REQ-QML-018-AC01 | [Project/header adoption evidence](#project-and-header-adoption); [final combined installed profile](#final-adoption-delivery) | INC-QML-08a | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified |
+| REQ-QML-018-AC02 | [Native syntax and cache upgrade evidence](../docs/qt-qml/VALIDATION.md#inc-qml-08a-native-source-compatibility); [reference-callable evidence](#declared-providers-and-reference-callables); final installed matrix below | INC-QML-08a/29 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified |
+| REQ-QML-018-AC03 | [Declared provider and reference-callable evidence](#declared-providers-and-reference-callables); final installed matrix below | INC-QML-08b/29 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified |
+| REQ-QML-018-AC04 | [Declared provider and reference-callable evidence](#declared-providers-and-reference-callables); logical direction and mechanism/callback integrity regressions; final installed matrix below | INC-QML-08b/30/36/37 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified |
+| REQ-QML-018-AC05 | `tests/test_qt_combined_adoption.py::test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates`; four actual isolated installed CLI profiles and selected installed lifecycle suite; final matrix below | INC-QML-08c/32/38 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified |
+| REQ-QML-018-AC06 | `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_rejection_retains_cache_products_and_retries`; `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_readonly_destination_preserves_then_retries`; direction/repeat/publication regressions mapped below; final installed matrix | INC-QML-08c/31–38 | Locally Verified (bounded Windows source/installed profile); wider hosted matrix unverified |
+
+## HTML community-view criteria
+
+REQ-QML-019-AC01–AC03 retain local verification at the Python exporter/CLI and
+emitted JavaScript boundaries. INC-QML-16 changes AC04 by removing Overview; its
+current control acceptance is locally verified at emitted-script and reviewed
+installed-artifact boundaries. Native browser/device behavior remains unverified.
+Node harnesses execute production scripts with the external
+vis network isolated; they prove dataset admission, controls and source payload,
+not a new browser-engine or visual acceptance run. Installed-artifact identity and
+executed commands are recorded in VALIDATION.md.
+
+| Criterion | Exact production evidence | Increment | Status |
+| --- | --- | --- | --- |
+| REQ-QML-019-AC01 | [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_large_export_recovers_complete_partition`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_invalid_saved_membership_is_rebuilt_without_stale_names`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_cli_exports_unclustered_saved_graph_without_sidecars`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac01_explicit_graph_uses_adjacent_analysis_not_malformed_cwd_sidecar`) | INC-QML-09 | Historical complete-fork evidence; deferred for this submission. Locally verified |
+| REQ-QML-019-AC02 | [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac02_existing_groups_get_missing_labels`); small/empty/authoritative group and name cases in the same module; `tests/test_qt_graph_html_payload.py::test_qml013_ac03_aggregated_html_explicitly_states_source_fact_omission`; existing escaping and written-node-info runtime regressions in `tests/test_export.py`; [current exact community-count assignments](#constructor-and-source-containment-corrections) | INC-QML-09; INC-QML-13 correction | Historical complete-fork evidence; deferred for this submission. Locally verified exporter/emitted-script counts and reviewed installed artifact; browser visual inspection is separate |
+| REQ-QML-019-AC03 | [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_invalid_computed_partition_preserves_output_and_recovers`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_cli_unavailable_view_retains_prior_outputs_and_retries`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_cli_failure_has_no_false_write_and_preserves_prior_html`); [frozen test_html_community_recovery owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_community_recovery.py) (`test_req_qml019_ac03_isolate_partition_over_hard_cap_is_not_published`) | INC-QML-09 | Historical complete-fork evidence; deferred for this submission. Locally verified; actual atomic replacement failure injected at its OS boundary |
+| REQ-QML-019-AC04 | [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_default_select_all_constructs_every_exported_node_and_edge_before_network`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_req_qml019_ac04_filters_all_none_preserve_endpoint_safe_source_data`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_req_qml019_ac04_search_restores_filtered_source_and_exact_metadata`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_small_grouped_and_ungrouped_views_default_to_select_all`); [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py) (`test_req_qml019_ac04_partial_membership_cannot_mark_hidden_ungrouped_fact_selected`) | INC-QML-09; INC-QML-16 removal | Historical complete-fork evidence; deferred for this submission. Locally verified current emitted-script controls and reviewed installed-artifact scripts. Native browser/device behavior unverified; earlier Overview results are revision-specific and superseded |
+
+
+
+## Project-membership projection
+
+Owner: Qt project-metadata integration maintainer. INC-QML-14 owns all three
+REQ-QML-020 criteria. Status: **Locally Verified for the bounded static public
+profile**. The new production tests below have executed source, lifecycle,
+consumer and reviewed installed-artifact evidence.
+Current source/resource lookup, source-file containment and community edge counts
+remain evidence for their existing boundary. Each independent membership site
+must preserve declaration/target identity, direction, provenance and uncertainty
+through the actual graph/consumer lifecycle. Qt policy 6/schema 7 at package
+version 0.9.74 has source upgrade and reviewed installed public-fixture evidence.
+Additional accepted-code-scope adoption checks pass; whole-root/provider/runtime
+adoption gaps remain outside this closure.
+
+| Criterion | Exact executed production evidence | Completion increment | Status |
+| --- | --- | --- | --- |
+| REQ-QML-020-AC01 | `tests/test_qt_project_membership.py::test_req_qml020_ac01_unused_component_has_persisted_build_and_resource_membership` (CMake/qmake; directed/undirected); `tests/test_qt_project_membership.py::test_req_qml020_ac01_metadata_spans_survive_bom_crlf_unicode_and_sanitation`; `tests/test_qt_project_membership.py::test_req_qml020_ac01_scoped_paths_repeated_declarations_and_query_keep_exact_identity`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac01_unused_packaged_component_survives_aggregate_export`. Targets uniquely accepted file/component endpoints, independent/repeated sites and same-name scoped files, `EXTRACTED` references, direction and original spans through facade/build/JSON reload/scoped query, unchanged loader/module lookup and actual aggregate-script membership counts. Source, consumer and final reviewed installed public-fixture evidence pass. | INC-QML-14 | Locally Verified (bounded static public profile) |
+| REQ-QML-020-AC02 | `tests/test_qt_project_membership.py::test_req_qml020_ac02_source_projection_rejects_unproved_membership`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_resources_reuse_existing_alias_guard_decisions`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_corrupt_literal_transport_is_rejected`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_location_prefix_cannot_authorize_another_source_span`; `tests/test_qt_project_membership.py::test_req_qml020_ac02_projection_reads_no_targets_and_executes_no_corpus`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac02_failed_metadata_join_preserves_products_and_recovers` (manual/force/watch); `tests/test_qt_project_membership_updates.py::test_req_qml020_ac02_join_exception_cannot_publish_partial_memberships`. Missing, duplicate, conditional, generated, wrong-role and out-of-root cases must have explicit status/reason and no target edge. The actual helper executes before injected failure; graph/manifest/analysis/root-marker bytes must remain unchanged. Projection guards target reads/discovery/process execution and preserves borrowed facts. All assigned source rejection/retention and final installed public-fixture checks pass. | INC-QML-14 | Locally Verified (bounded static public profile) |
+| REQ-QML-020-AC03 | `tests/test_qt_project_membership.py::test_req_qml020_ac03_fresh_derived_sites_replace_borrowed_state_without_mutation`; `tests/test_qt_project_membership.py::test_req_qml020_ac03_typed_file_role_survives_punctuation_and_borrowed_publication`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_metadata_resource_and_source_updates_remove_stale_memberships` (manual/watch); `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_policy_upgrade_refreshes_unchanged_packaging` (extract/update); `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_membership_ids_are_independent_of_checkout_location`; `tests/test_qt_project_membership_updates.py::test_req_qml020_ac03_pipeline_publishes_replacement_without_borrowed_mutation`; malformed-resource recovery and aggregate-export tests above. Targets cold/warm/full/manual/watch parity, source edit/rename with metadata/alias updates, alias duplicates, target deletion, stale-edge removal, stable unrelated Python/relocated root identity, accepted typed-file identity through punctuation/publication, fresh-site replacement without borrowed mutation and policy-5 to policy-6 refresh without source/package changes. Complete source/lifecycle/consumer and reviewed installed public-fixture evidence pass for the bounded profile. | INC-QML-14 | Locally Verified (bounded static public profile) |
+
+The first actual lifecycle selection failed all four cases in 3.76 seconds before
+projection existed: the public fixture had no `membership_resolution` sites.
+The failure record is baseline regression proof. Exact executed commands,
+artifact identity and remaining broader limitations are in
+[validation](../docs/qt-qml/VALIDATION.md#inc-qml-14-membership-projection).
+The frozen source-projection module passes 26 cases in 1.90 seconds; its positive
+facade cases fail twice with only projection disabled and actual parsing/indexes
+retained. The reviewed-wheel checkpoint's combined selection passes 37 cases in
+8.77 seconds. Two added directed graph variants then pass within a final
+39-case focused selection in 9.50 seconds, with no production change. The
+reviewed-wheel broad selection passes 1081 cases with seven documented skips and
+one existing warning in 135.53 seconds. All 155 Python modules are byte-equal
+between reviewed source, wheel and isolated installation. The installed public
+CMake/qmake/qrc fixture has six resolved membership sites, accepted consumer/HTML
+results, and actual force/partial failure retention followed by repair/repeat.
+Browser visual, other-platform and executable Qt proof are not claimed. The
+additional accepted-code-scope adoption refresh also passes. That INC-QML-14
+checkpoint contained 81 criteria; the subsequent camera requirement adds three
+without changing earlier criterion identities or revision evidence.
+
+## Temporary middle-button camera and Overview removal
+
+Owner: HTML viewer maintainer. INC-QML-16 owns REQ-QML-021-AC01–AC03 and the changed
+REQ-QML-019-AC04, depending on the existing REQ-QML-019 community-view contract.
+Status: **Locally verified at emitted-script and reviewed installed-artifact
+boundaries; native browser/device and other-platform behavior unverified**.
+The tests below execute the actual
+emitted navigation script with a recording camera/network boundary; a stubbed
+network does not establish a browser-engine, layout or native-device result.
+The initial-view module separately retains checked startup, controls, dataset and
+source metadata assertions while removing obsolete Overview expectations.
+
+| Criterion | Exact production evidence and boundaries | Increment | Status |
+| --- | --- | --- | --- |
+| REQ-QML-021-AC01 | [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac01_middle_drag_translates_both_axes_at_current_zoom`) (24 horizontal/vertical/diagonal, four-scale, source/aggregate variants); incremental client delta divided by current zoom, preserved scale, no animation, and suppressed native middle autoscroll | INC-QML-16 | Historical complete-fork evidence; deferred for this submission. Locally verified emitted-script and reviewed installed-artifact boundary; native browser/device and other platforms unverified |
+| REQ-QML-021-AC02 | [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_release_cancel_blur_and_lost_buttons_end_drag`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_capture_failure_has_window_fallback_and_no_lingering_drag`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_invalid_camera_or_input_aborts_without_jump`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_invalid_press_cannot_capture_or_resume_after_repair`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac02_unrelated_pointer_and_nonmiddle_release_preserve_owned_drag`). Matching release/cancel, lost button/capture, blur/pagehide, outside movement/capture fallback, invalid finite/scale/derived-position and cursor restoration controls prove termination and no later jump | INC-QML-16 | Historical complete-fork evidence; deferred for this submission. Locally verified emitted-script and reviewed installed-artifact boundary; native browser/device and other platforms unverified |
+| REQ-QML-021-AC03 | [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac03_other_inputs_and_source_datasets_remain_unchanged`); [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py) (`test_req_qml021_ac03_filters_and_search_work_after_middle_drag`); left/right/touch/wheel coexistence and actual startup/filter/all/none/search/inspector controls preserve graph/payload/dataset metadata, node positions, physics and temporary camera-only state | INC-QML-16 | Historical complete-fork evidence; deferred for this submission. Locally verified emitted-script and reviewed installed-artifact boundary; native browser/device and other platforms unverified |
+
+The focused command executes both current modules:
+
+Historical invocation/source excerpt: [frozen TRACEABILITY lines 1033–1036](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/TRACEABILITY.md#L1033-L1036). Deferred owners: [frozen test_html_middle_pan owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_middle_pan.py), [frozen test_html_initial_view owner](https://github.com/SlinkyRamey/graphify/blob/82a4f296446b4cc219ffecfe3345a037e9c362e4/tests/test_html_initial_view.py).
+
+Result: **60 passed**, 6.57 seconds, with no skips, after restoring retained Qt
+projection, metadata/span/attribute and endpoint-safe source-edge assertions.
+The earlier 60-pass/6.71-second checkpoint precedes that assertion review; the
+44-pass/4.99-second checkpoint precedes independent horizontal/vertical/diagonal expansion.
+An earlier missing-hook selection has 28 failed cases; the integration owner's validation record owns its
+exact command/revision. Earlier exporter/CLI/membership-consumer regression proof
+passes 219 cases in 54.20 seconds before the sixteen additional geometry cases.
+The final exporter/CLI/membership/inspector selection passes 231 cases in 53.15
+seconds, without failures or skips.
+Wheel-artifact/Qt HTML consumers pass seven cases in 4.60 seconds, and the legacy
+export module passes 67 cases in 2.95 seconds. These recorded script/artifact
+boundaries do not close native browser/device behavior. The final 231-case
+selection and seven additional consumer/wheel cases cover 238 distinct cases.
+Five touched owners pass Ruff; the four navigation/source/test owners pass
+explicit-runtime Pyright with zero errors/warnings. Including the legacy
+`tests/test_export.py` owner reports one unchanged `reportOptionalOperand` error
+at line 1008, reproduced against the prior revision with the same explicit
+include/exclude configuration. The narrow listener-registration adjustment does
+not alter that optional-return expression, inspector assertions or type checks.
+
+The reviewed production tree is `a991dd9fb2d638cf15fbb2c76499ed1f6ebd51ea`; wheel
+SHA256 is `11e370a19d328a01b4e9e30726731c815b4ef58399833ff3d0a7c644f71e98f0`.
+All 156 Python payloads match reviewed source, wheel and isolated installation.
+The installed viewer artifact retains canonical/RAW data, checked startup and
+source inspectors; actual emitted-script camera moves and release/blur/retry
+cleanup pass. No private input identifiers, paths or source are retained here.
+Helper/HTML/initial-view/middle-pan measurements are 116/789/245/223 physical lines;
+the updated inspector harness remains within its documented 1377-line exception.
+Qt policy 6 and AST schema 7 are unchanged; this is viewer input, with no source
+execution, new SDK, persistence operation or graph diagnostic. Earlier Overview
+and camera selection evidence is historical. A changed requirement is verified
+only after its own applicable controls and failure/cleanup cases pass; broader
+adoption, inherited endpoint and constructor-overload gaps remain independent.
+
+## Follow-up audit scope corrections
+
+Inspected production revision: `95adbdc165f44a96bf275a7870bb1da4d82a5bea`.
+Public static fixtures; Python 3.12.14, tree-sitter 0.25.2 and language-pack 0.11.0;
+Windows. No Qt SDK or corpus execution. The probes are explicit opt-in diagnostic
+tests and retain correct failing assertions. Their default-discovery exclusion is
+temporary audit ownership, not regression completion. The correction owner must
+promote them into normal collection and reverify all affected criteria.
+
+| Acceptance | Exact probe or system assignment | Outcome / correction owner |
+| --- | --- | --- |
+| REQ-QML-017-AC02/AC04 | `tests/audit/probe_qt_qml_object_boundaries.py::test_cpp_reflective_child_property_does_not_inherit_qml_lexical_root` (read/write/invoke variants) | Three persisted-edge rejections fail; INC-QML-17 reverse-access resolver owner |
+| REQ-QML-017-AC02/AC04 | `tests/audit/probe_qt_qml_object_boundaries.py::test_cpp_findchild_does_not_search_sibling_outside_receiver_subtree`; `tests/audit/probe_qt_qml_object_boundaries.py::test_cpp_findchild_direct_search_cannot_select_grandchild` | Both persisted-edge rejections fail; INC-QML-17 |
+| REQ-QML-017-AC03/AC04 | `tests/audit/probe_qt_qml_object_boundaries.py::test_disjoint_engine_scopes_cannot_supply_context_provider` | Provider edge reaches another engine; rejection fails; INC-QML-18 context integration owner |
+| REQ-QML-017-AC02/AC03 | `tests/audit/probe_qt_qml_object_boundaries.py::test_public_receiver_and_engine_positive_controls` | Own-object/root/engine controls pass; they do not validate failing scopes |
+| REQ-QML-018-AC03 | `tests/audit/probe_qt_qml_object_boundaries.py::test_pending_typed_factory_provider_remains_unresolved` | Historical baseline exclusion only at 95adbdc; superseded by ordinary positive typed-provider regressions in INC-QML-08b. This opt-in probe is not current acceptance |
+| REQ-QML-016-AC02/AC03/AC04 | `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml016_connect_alias_cannot_select_unrelated_global_signal` (using/typedef); `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml016_block_alias_does_not_change_before_and_after_native_scope` | Three wrong persisted-endpoint cases fail; INC-QML-19 native type/endpoint owners |
+| REQ-QML-016-AC01/AC03/AC04 | `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml016_emission_alias_cannot_select_unrelated_global_signal` (using/typedef) | Two wrong emission endpoints fail; INC-QML-19 |
+| REQ-QML-008-AC01/AC03 | `tests/audit/probe_qt_native_type_shadowing.py::test_req_qml008_alias_registration_cannot_export_global_class_to_qml` (using/typedef) | Two wrong native-to-QML registration/handler cases fail; INC-QML-19 |
+| REQ-QML-008-AC01 and REQ-QML-016-AC01/AC02/AC04 | `tests/audit/probe_qt_native_type_shadowing.py::test_direct_native_type_control_preserves_roles_and_direction`; `tests/audit/probe_qt_native_type_shadowing.py::test_direct_registration_control_preserves_native_qml_endpoint` (Sender/Other variants) | Four direct native/QML controls pass through actual publication and directed reload |
+| REQ-QML-021-AC01–AC03 and REQ-QML-019-AC04 | [Native navigation review procedure](../docs/qt-qml/VIEWER_SYSTEM_REVIEW.md) | Historical complete-fork evidence; deferred for this submission. Exact fixture/actions/evidence/failure/cleanup/owner defined; physical browser/device/platform execution remains unverified |
+| REQ-QML-017-AC01/AC04 | `tests/audit/probe_qt_loader_forms.py::test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance` (engine URL constructor/component loadUrl/engine.load variants) | Two loader-admission assertions fail; engine.load control passes; INC-QML-20 collector/integration owner |
+
+Executed command:
+
+```text
+.venv/Scripts/python.exe -X utf8 -m pytest tests/audit/probe_qt_qml_object_boundaries.py tests/audit/probe_qt_native_type_shadowing.py tests/audit/probe_qt_loader_forms.py -q --tb=short
+```
+
+Final combined result: **15 failed, 7 passed in 3.38 seconds**, one existing Hypothesis collection
+warning. Thirteen failing variants expose four wrong-target root causes; two more expose loader omissions.
+The independent existing lifecycle selection passed **67 tests in 21.43 seconds**;
+the final native/QML compatibility selection passed **60 tests in 4.85 seconds**.
+Neither establishes cold/warm/manual/watch parity for a correction not implemented.
+At that baseline audit, reviewed installed-wheel, complete repository suite and
+new hosted proof were unexecuted for the corrections. Current per-increment
+package proof and repository gate failures are recorded in validation; new hosted
+proof remains unexecuted. See [audit](../docs/qt-qml/FOLLOWUP_AUDIT.md)
+and [plan](../docs/qt-qml/PLAN.md) for scope, dependencies and delivery gates.
+
+## Receiver correction evidence (INC-QML-17)
+
+These current mappings supplement the historical audit/profile rows above.
+The reviewed installed artifact passes 98 selected cases with no skips/failures;
+exact wheel identity and dependency limits are in
+[validation](../docs/qt-qml/VALIDATION.md#inc-qml-17-receiver-ownership-and-construction-trees).
+Full contribution gates ran at INC-QML-20; documented baseline failures remain.
+
+| Acceptance | Automatically collected production evidence | Current boundary |
+| --- | --- | --- |
+| REQ-QML-017-AC02 | `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_child_reflection_cannot_use_lexical_root`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_findchild_rejects_siblings_depth_and_receiver`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_property_held_qobject_has_construction_owner`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac04_native_base_members_and_property_child_keep_source_proof`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_inherited_member_is_receiver_owned_and_readonly_write_rejected` | Partial; receiver correction passes; static reflection correction has separate INC-QML-22 evidence below |
+| REQ-QML-017-AC02/AC04 | `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_duplicate_names_are_ambiguous_only_inside_receiver`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_parent_bindings_and_js_writes_do_not_authorize_construction_tree`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_unknown_types_and_component_templates_remain_unavailable`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_construction_lookup_requires_accepted_declaration_proof`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_proved_cpp_parent_mutation_keeps_source_but_no_target`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_unsupported_findchild_options_cannot_choose_target`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_unproved_findchild_type_filter_cannot_choose_object`; `tests/test_qt_receiver_boundaries.py::test_req_qml017_ac02_noncreatable_singleton_and_gadget_cannot_prove_qobject_creation` | Rejection/ambiguity and accepted source-construction proof pass without guessed targets |
+| REQ-QML-017-AC04 | `tests/test_qt_semantic_correction_updates.py::test_req_qml017_ac04_receiver_tree_and_member_updates_remove_stale_edges`; `tests/test_qt_semantic_correction_updates.py::test_req_qml017_ac04_policy_refresh_failure_retains_products_and_retry`; `tests/test_qt_semantic_correction_updates.py::test_req_qml017_ac04_failed_completion_keeps_prior_graph_and_retries` | Partial; bounded receiver/provider/loader lifecycle passes; INC-QML-21/22 have separate evidence below; wider system gaps remain |
+
+Existing QML scope/declaration/identity, loader, provider and reverse-update tests
+also pass against that reviewed wheel. Factory providers, inherited native signal
+ancestors and generic overload identity retain their separately recorded gaps.
+
+## Declaration identity correction evidence (INC-QML-18)
+
+These ordinary collected regressions replace A12's opt-in reproduction as current
+acceptance evidence. Native/system lifetime and broader provider adoption remain
+explicit gaps; the source profile does not execute a Qt engine.
+
+| Acceptance | Exact production-boundary test | Observable evidence |
+| --- | --- | --- |
+| REQ-QML-017-AC03; REQ-QML-008-AC01 | `tests/test_qt_engine_identity.py::test_req_qml017_ac03_disjoint_same_named_engines_cannot_share_provider`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_same_engine_positive_retains_persisted_provider`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_nested_engine_shadow_does_not_receive_outer_provider`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_local_provider_lifetime_cannot_supply_later_load`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_engine_and_provider_parameters_have_exact_identity` | Disjoint/nested engines and expired providers have no persisted binding; exact local/parameter positive controls retain component and native property endpoints |
+| REQ-QML-017-AC03/AC04 | `tests/test_qt_engine_identity.py::test_req_qml017_ac03_rejected_identity_preserves_precise_source_diagnostic`; `tests/test_qt_engine_identity.py::test_req_qml017_ac03_initial_properties_report_only_shared_identity_failure`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_writes_and_duplicate_declarations_fail_closed`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_conditional_and_deferred_lifetimes_have_no_identity`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_missing_or_corrupted_transport_cannot_fall_back_to_name` | Source-owned rejection reasons, mixed initial properties and corrupted transport never authorize a name-based fallback |
+| REQ-QML-017-AC04 | `tests/test_qt_engine_identity.py::test_req_qml017_ac04_auto_and_explicit_handles_keep_declaration_identity`; `tests/test_qt_engine_identity.py::test_req_qml017_ac04_ids_are_portable_with_original_unicode_bom_crlf_spans`; `tests/test_qt_engine_identity_updates.py::test_req_qml017_ac04_engine_scope_changes_remove_stale_provider_links`; `tests/test_qt_engine_identity_updates.py::test_req_qml017_ac04_malformed_identity_source_retains_products_and_retries`; `tests/test_qt_engine_identity_updates.py::test_req_qml017_ac04_directed_query_affected_and_reload_keep_scoped_provider` | Partial; bounded receiver/provider/loader lifecycle passes; INC-QML-21/22 have separate evidence below; wider system gaps remain |
+
+Exact source/artifact commands and outcomes belong to the corresponding validation
+section. Full contribution gates run at the INC-QML-20 integration boundary.
+
+## Native alias and ancestry correction evidence (INC-QML-19)
+
+Ordinary collected cases supersede A13's opt-in probe as current bounded source
+evidence. Imported-header targets, inherited event endpoints and generic overload
+identity remain explicitly excluded or assigned to INC-QML-11/15.
+
+| Acceptance | Exact test | Evidence |
+| --- | --- | --- |
+| REQ-QML-016-AC01/AC02/AC03/AC04 | `tests/test_qt_native_alias_scope.py::test_req_qml016_connect_alias_cannot_select_unrelated_global_signal`; `tests/test_qt_native_alias_scope.py::test_req_qml016_emission_alias_cannot_select_unrelated_global_signal`; `tests/test_qt_native_alias_scope.py::test_req_qml016_block_alias_does_not_change_before_and_after_native_scope`; `tests/test_qt_native_alias_scope.py::test_direct_native_type_control_preserves_roles_and_direction`; `tests/test_qt_native_alias_scope.py::test_namespace_lookup_and_namespace_alias_keep_original_byte_spans` | Source-local alias rejection/positive controls preserve canonical roles, relation distinctions and original spans |
+| REQ-QML-008-AC01/AC03 | `tests/test_qt_native_alias_scope.py::test_req_qml008_alias_registration_cannot_export_global_class_to_qml`; `tests/test_qt_native_alias_scope.py::test_direct_registration_control_preserves_native_qml_endpoint`; `tests/test_qt_native_alias_scope.py::test_proven_alias_registration_exports_the_actual_class_and_qml_signal`; `tests/test_qt_native_alias_scope.py::test_local_alias_positive_and_rejection_profile` | Registration/QML projections select accepted aliases or retain no guessed provider |
+| REQ-QML-016-AC01–AC04; REQ-QML-008-AC01/AC03 | `tests/test_qt_native_alias_updates.py::test_alias_type_edit_and_removal_refreshes_native_qml_cold_warm_consumers`; `tests/test_qt_native_alias_updates.py::test_unprovable_alias_replaces_old_edges_without_global_name_fallback`; `tests/test_qt_native_alias_updates.py::test_bad_native_alias_input_retains_outputs_then_recovers_and_repeats`; `tests/test_qt_native_alias_updates.py::test_accepted_header_alias_cannot_resolve_outer_global_native_or_qml_endpoint`; `tests/test_qt_native_alias_updates.py::test_transitive_header_alias_owns_shadow_but_not_prior_source_use`; `tests/test_qt_native_alias_updates.py::test_included_alias_fact_corruption_rejects_provenance_instead_of_resolving_outer_type`; `tests/test_qt_native_alias_updates.py::test_fresh_alias_header_uses_borrowed_qt_context_without_mutating_it` | Real manual/watch full/cold/warm, stale-edge removal, persisted/query/affected, parse/write failure retention and retry; included alias provenance rejection and borrowed-context isolation |
+| REQ-QML-017-AC02/AC04 | `tests/test_qt_construction_authority.py::test_req_qml017_ac02_widget_and_unknown_ancestry_cannot_authorize_child_tree`; `tests/test_qt_construction_authority.py::test_req_qml017_ac04_direct_and_source_defined_qobject_chain_survives_publication`; `tests/test_qt_construction_authority.py::test_req_qml017_ac02_native_ancestry_reads_only_accepted_complete_source_facts`; `tests/test_qt_construction_authority.py::test_req_qml017_ac04_parent_mutation_spans_are_bounded_original_bytes`; `tests/test_qt_reflection_type_aliases.py::test_req_qml017_ac02_findchild_filter_rejects_shadowed_sdk_type` | Widget/unknown/corrupt/native shadow rejection; direct/derived non-widget controls, original 50/51 mutation-span boundary and unshadowed QObject filter |
+
+Exact executed source/artifact evidence is retained in validation; whole-requirement
+verification remains bounded by recorded adoption/inheritance/overload/system gaps.
+
+At the INC-QML-19 checkpoint same-file qualified canonical classes remained an
+implementation gap under REQ-QML-008-AC02, REQ-QML-016-AC01/AC04 and
+REQ-QML-017-AC02/AC04. The original negative control proved safe rejection.
+INC-QML-21 adds independent producer identities and collected proof/update cases
+below; the genuine same-file native-versus-widget assertion now proves exact
+ownership without weakening the unrelated rejection controls.
+
+## Literal loader correction evidence (INC-QML-20)
+
+| Acceptance | Exact ordinary test | Evidence |
+| --- | --- | --- |
+| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_provenance.py::test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance` | Existing engine.load control, URL engine constructor and component loadUrl/create retain original source spans, component/property targets and persisted direction |
+| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_supported_loader_overloads_have_one_source`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_local_file_wrapper_cannot_become_resource_url`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_absolute_fromlocalfile_retains_file_component`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_url_wrapper_shadow_cannot_lend_literal_argument`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_custom_component_engine_cannot_authorize_constructor`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_global_sdk_constructor_bypasses_namespace_shadow`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_sdk_name_shadow_cannot_authorize_loader`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac04_unknown_overload_or_creation_cannot_prove_root`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_constructor_mode_needs_sdk_type_authority`; `tests/test_qt_loader_boundaries.py::test_req_qml017_ac01_string_wrapper_requires_source_authority` | Literal controls, parent/mode/context boundaries, absolute file vs resource/relative/computed URLs and source-defined class/alias/callable rejection |
+| REQ-QML-017-AC03/AC04 | `tests/test_qt_loader_providers.py::test_req_qml017_ac03_component_load_url_uses_its_exact_engine_provider`; `tests/test_qt_loader_providers.py::test_req_qml017_ac03_component_engine_shadow_cannot_borrow_another_provider`; `tests/test_qt_loader_providers.py::test_req_qml017_ac03_component_engine_assignment_condition_and_factory_reject` | Named/context-object provider joins use exact component-owned engine identity; shadows/reassignment/conditional/factory results cannot lend provider authority |
+| REQ-QML-017-AC04 | `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_edits_match_cold_warm_manual_and_watch`; `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_parse_failure_preserves_products_and_recovers`; `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_write_failure_preserves_products_and_retries`; `tests/test_qt_loader_updates.py::test_req_qml017_ac04_loader_directed_reload_query_and_affected_keep_endpoints` | Real source/resource/member edits and removal, cold/warm/manual/watch parity, stale-edge removal, four-product parse/replace failure retention, repaired retry/idempotency and actual persisted query/affected endpoints |
+| REQ-QML-001-AC01/AC02; REQ-QML-003-AC01 | `tests/test_languages.py::test_qml_language_facade_preserves_source_declarations_and_spans` | CONTRIBUTING language convention, production facade and hand-checked declaration/original-byte spans; optional-parser absence is explicit skip, not acceptance |
+
+The INC-QML-20 checkpoint retained failing opt-in INC-QML-22 cases under REQ-QML-017-AC02/AC04:
+`tests/audit/probe_qt_reflection_type_shadowing.py::test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target`
+and its positive control
+`tests/audit/probe_qt_reflection_type_shadowing.py::test_req_qml017_ac04_unshadowed_sdk_reflection_preserves_targets_and_provenance`.
+At the audited baseline twelve failures and two passes established the gap.
+The opt-in probe remains excluded historical evidence; the ordinary INC-QML-22
+regressions below now exercise the corrected rejection and positive controls.
+
+The INC-QML-20 checkpoint retained the actual Windows failure assigned to INC-QML-23:
+`tests/test_atomic_writes.py::test_write_text_atomic_refuses_a_readonly_destination_without_leaking_a_temp`
+under REQ-QML-018-AC06. It failed on the original baseline and INC-QML-20
+checkpoint. INC-QML-23/25 ordinary tests below cover the corrected actual OS
+retention and caller completion boundary; wider adoption remains unverified.
+
+## Follow-up correction evidence (INC-QML-21–27)
+
+These cases extend the bounded profile; they do not close all wider adoption,
+inherited-endpoint or overload criteria. Exact final commands, installed artifact
+identity and contribution gate limitations are recorded in validation.
+
+| Acceptance IDs | Ordinary production-boundary tests | Evidence scope |
+| --- | --- | --- |
+| REQ-QML-008-AC02; REQ-QML-016-AC01/AC04; REQ-QML-017-AC02/AC04 | `tests/test_cpp_qualified_identity.py::test_req_qml008_ac02_qualified_class_bodies_keep_distinct_portable_identities`; `tests/test_cpp_qualified_proof.py::test_req_qml008_ac02_using_namespace_bound_cannot_discard_competing_owner`; `tests/test_cpp_qualified_updates.py::test_req_qml017_ac04_qualified_owner_edits_match_cold_warm_updates_and_consumers` | Qualified IDs/body proof, signature/cap rejection, original bytes, canonical calls/emissions, full/manual/watch and affected preservation |
+| REQ-QML-017-AC02/AC04 | `tests/test_qt_reflection_type_identity.py::test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target`; `tests/test_qt_reflection_type_identity.py::test_req_qml017_ac04_sdk_reflection_query_and_affected_use_exact_persisted_site`; `tests/test_qt_reflection_updates.py::test_req_qml017_ac04_reflection_source_qml_metadata_edits_remove_stale_targets`; `tests/test_qt_reflection_updates.py::test_req_qml017_ac04_reflection_parse_failure_retains_four_products_and_recovers`; `tests/test_qt_reflection_updates.py::test_req_qml017_ac04_reflection_write_failure_retains_four_products_and_retries` | Collected replacement for excluded reflection probe; strict whole-graph lifecycle and real failure/retry |
+| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac01_incomplete_sdk_declaration_cannot_authorize_loader`; `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac04_uncertain_sdk_use_retains_observed_loader`; `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac04_loader_forward_edits_retire_and_restore_targets`; `tests/test_qt_loader_sdk_declarations.py::test_req_qml017_ac04_loader_forward_failure_retains_products_and_recovers` | SDK/source controls through JSON reload, full/manual/watch edits, uncertain facts, stale-edge removal and durable parse/publication recovery |
+| REQ-QML-018-AC06; REQ-QML-011-AC04; REQ-QML-013-AC03 | `tests/test_atomic_replace_retention.py::test_req_qml018_ac06_readonly_rejects_without_displacing_destination`; `tests/test_atomic_replace_retention.py::test_req_qml018_ac06_fallback_failure_restores_prior_state`; `tests/test_atomic_replace_retention.py::test_req_qml018_ac06_restore_failure_keeps_recovery_backup`; `tests/test_qt_readonly_publication.py::test_req_qml018_ac06_readonly_graph_preserves_products_then_retries` | Actual Windows OS read-only failure, fallback ordering, retention and guarded caller retry; system/platform gaps remain separate |
+| REQ-QML-012-AC02; REQ-QML-018-AC06; REQ-QML-011-AC04 | `tests/test_qt_product_publication.py::test_inc25_readonly_each_product_preserves_cohort_and_retries`; `tests/test_qt_product_publication.py::test_inc25_preparation_and_late_replace_faults_preserve_then_retry`; `tests/test_qt_product_publication.py::test_inc25_first_build_failure_publishes_no_acceptance_products`; `tests/test_publication.py::test_inc25_actual_rollback_fault_retains_recovery_copies`; `tests/test_publication.py::test_inc25_cleanup_failure_does_not_misreport_cohort_authority` | Actual durable acceptance cohort, first-build and later-stage failures, rollback integrity/cleanup and independent valid AST cache boundary |
+| REQ-QML-011-AC02/AC04; REQ-QML-017-AC04 | `tests/test_qt_orphan_cleanup.py::test_req_qml017_ac04_watch_restored_sdk_source_has_no_stale_generic_placeholder`; `tests/test_qt_orphan_cleanup.py::test_req_qml011_ac04_complete_refresh_keeps_semantic_connected_and_hyperedge_stub_context`; `tests/test_qt_orphan_cleanup.py::test_req_qml011_ac04_partial_refresh_is_not_authority_to_remove_placeholder` | Strict full graph parity, immutable borrowed state and preserved live/semantic/hyperedge/source ownership |
+| REQ-QML-007-AC01/AC03; REQ-QML-008-AC02/AC03; REQ-QML-017-AC03/AC04 | `tests/test_qt_native_property_notify.py::test_req_qml007_ac03_native_property_handler_maps_custom_and_conventional_notify`; `tests/test_qt_native_property_notify.py::test_req_qml008_ac02_shared_notify_preserves_two_independent_handler_sites`; `tests/test_qt_native_property_notify.py::test_req_qml007_ac03_notify_binds_only_block_injection_or_explicit_formals`; `tests/test_qt_native_property_notify.py::test_req_qml017_ac04_native_notify_query_and_affected_use_canonical_signal` | Real canonical signal/provider subscriptions, invalid notify rejection, explicit/legacy binding, reload/query/affected; wider native inheritance remains unverified |
+
+The [exposure chapter review](../docs/qt-qml/EXPOSURE_CHAPTER_REVIEW.md) names the
+official mechanism checklist. Macro accountability is separately recorded in the
+API inventory. Excluded audit probes remain historical evidence and cannot by
+themselves verify release acceptance. Corrected scenarios use ordinary discovery.
+
+Native parameter/lifecycle mappings additionally cover:
+
+| Acceptance IDs | Exact collected test | Scope |
+| --- | --- | --- |
+| REQ-QML-007-AC01/AC03 | `tests/test_qml_handler_formals.py::test_req_qml007_ac03_source_handler_formals_preserve_property_dependency`; `tests/test_qml_handler_formals.py::test_req_qml007_ac03_handler_authority_survives_parameter_display_limit`; `tests/test_qml_handler_formals.py::test_req_qml007_ac03_signal_parameter_overflow_rejects_without_truncation`; `tests/test_qml_handler_formals.py::test_req_qml007_ac03_handler_change_does_not_reclassify_unrelated_javascript` | Pure-QML/native explicit binding, display bounds and unchanged ordinary JS classification |
+| REQ-QML-007-AC03; REQ-QML-008-AC02/AC03; REQ-QML-017-AC03/AC04 | `tests/test_qt_native_notify_updates.py::test_req_qml017_ac04_notify_edits_remove_stale_edges_with_full_parity`; `tests/test_qt_native_notify_updates.py::test_req_qml017_ac04_notify_parse_failure_retains_products_and_recovers`; `tests/test_qt_native_notify_updates.py::test_req_qml017_ac04_notify_manifest_failure_rolls_back_and_retries` | Cold/warm, directed/default, source/QML/qmake edits, manual/force/watch rejection and post-graph sidecar rollback/recovery |
+
+| Acceptance IDs | Exact collected included-header/setup test | Scope |
+| --- | --- | --- |
+| REQ-QML-017-AC01/AC04 | `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac01_included_forward_class_blocks_sdk_loader_and_wrapper_authority`; `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac04_header_only_forward_edits_match_cold_warm_and_restore`; `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac04_header_shadow_failure_preserves_products_and_retries`; `tests/test_qt_loader_header_shadows.py::test_req_qml017_ac04_class_shadow_walk_overflow_rejects_instead_of_discarding_header` | Accepted literal include/provenance shadows, independent native target roles, header-only lifecycle, failure/retry and 129-header rejection |
+| REQ-QML-012-AC02; REQ-QML-018-AC06 | `tests/test_publication.py::test_inc25_setup_fault_has_safe_code_and_preserves_retry`; `tests/test_publication.py::test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence`; `tests/test_qt_product_publication.py::test_inc25_successful_ast_cache_does_not_authorize_failed_products` | Safe destination/setup diagnostics, owned partial setup cleanup and unchanged original successful cache-entry bytes with valid new entries |
+
+Final source and installed acceptance for INC-QML-21–27 is recorded in
+[validation](../docs/qt-qml/VALIDATION.md#final-local-follow-up-integration-evidence).
+All executed correction cases pass. The installed publication symlink case,
+existing atomic symlink/mode and external-symlink admission cases remain explicit
+host gaps; optional language skips do not establish acceptance. Full repository
+pytest and typing retain the separately recorded baseline failures. Broader
+adoption/inherited native endpoint/overload criteria remain Partially verified.
+
+
+## Inherited endpoints and pending adoption integration
+
+| Acceptance ID | Exact production-boundary evidence | State |
+| --- | --- | --- |
+| REQ-QML-016-AC01 | `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_grandparent_endpoints_use_declaring_members`; `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_diamond_deduplicates_declarations_and_rejects_conflicts`; `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_shadowing_precedes_role_signature_and_visibility`; `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac01_ancestor_traversal_has_a_fail_closed_32_class_budget`; `tests/test_qt_inherited_access.py::test_req_qml016_ac01_external_member_pointer_cannot_cross_nonpublic_base`; missing/corrupt access and lexical namespace controls in those modules | Locally verified inherited declaration/access profile; explicit missing-declaration emissions remain INC-QML-28 |
+| REQ-QML-016-AC04 | `tests/test_qt_inherited_endpoints.py::test_req_qml016_ac04_ancestor_edges_reach_reload_query_and_affected`; `tests/test_qt_inherited_updates.py::test_req_qml016_ac04_header_signal_base_and_site_edits_match_full_cold_warm`; `tests/test_qt_inherited_updates.py::test_req_qml016_ac04_malformed_ancestor_preserves_products_then_retries`; `tests/test_qt_inherited_updates.py::test_req_qml016_ac04_readonly_ancestor_refresh_preserves_cohort_and_retry` | Local manual/watch, reload/query/affected and real failure/retry pass; reviewed installed suite passes; wider hosted matrix unverified |
+| REQ-QML-018-AC07 | [Project/header adoption evidence](#project-and-header-adoption) | Local source/update pass; reviewed installed suite passes; wider hosted matrix unverified |
+
+Current adoption criterion count is seven, bringing the catalog to 85 acceptance
+criteria across 21 requirements. Earlier six-criterion counts describe the earlier
+profile. No criterion is renumbered. Runtime execution, arbitrary include search,
+Qt SDK equivalence and other platforms remain outside this bounded static proof.
+
+## Exact constructor overloads
+
+| Acceptance IDs | Exact ordinary production tests | State and boundary |
+| --- | --- | --- |
+| REQ-QML-008-AC02 | `tests/test_cpp_overload_identity.py::test_req_qml008_ac02_overload_producer_ids_spans_and_exact_declaration_merge`; `tests/test_cpp_overload_identity.py::test_req_qml008_ac02_constructor_ids_survive_overload_addition_removal_and_parameter_rename`; `tests/test_cpp_overload_identity.py::test_req_qml008_ac02_distinct_pointer_reference_and_builtin_types_never_collapse`; `tests/test_cpp_overload_type_authority.py::test_req_qml008_ac02_shadow_transport_spans_address_original_bom_crlf_unicode_bytes`; `tests/test_qt_overload_consumers.py::test_req_qml008_ac02_and_qml011_ac01_exact_overload_native_consumers` | Local producer/facade/source/export/query/affected pass; exact accepted signature and original-span profile. Reviewed installed suite passes; wider hosted matrix unverified. |
+| REQ-QML-008-AC03 | `tests/test_cpp_overload_identity.py::test_req_qml008_ac03_unsupported_signatures_keep_visible_distinct_occurrences_without_owner`; `tests/test_cpp_overload_identity.py::test_req_qml008_ac03_qualified_void_cannot_authorize_zero_argument_constructor`; `tests/test_cpp_overload_type_authority.py::test_req_qml008_ac03_angle_local_shadow_cannot_grant_sdk_constructor_identity`; `tests/test_cpp_overload_type_authority.py::test_req_qml008_ac03_include_walk_overflow_cannot_hide_sdk_shadow`; `tests/test_qt_overload_consumers.py::test_req_qml008_ac03_unknown_overload_signature_cannot_borrow_known_native_class` | Local uncertainty, duplicate/type/include/transport rejection pass. Aliases, templates, dependent/function-pointer/array/variadic signatures remain unsupported. |
+| REQ-QML-011-AC01 | `tests/test_cpp_overload_updates.py::test_req_qml011_ac01_ac04_overload_signature_edit_removal_and_repeat_parity`; `tests/test_qt_overload_consumers.py::test_req_qml008_ac02_and_qml011_ac01_exact_overload_native_consumers` | Local cold/warm/full/manual/watch, exact native ownership and canonical consumer parity pass. |
+| REQ-QML-011-AC04 | `tests/test_cpp_overload_updates.py::test_req_qml011_ac04_overload_publication_failure_retains_cohort_and_retries`; `tests/test_cpp_overload_updates.py::test_req_qml011_ac04_partial_constructor_parse_retains_accepted_identity_and_recovery`; `tests/test_cpp_overload_updates.py::test_req_qml011_ac04_schema9_name_id_fixture_migrates_unchanged_sources_to_exact_signatures` | Real OS and injected late publication failure, nonempty real cache retention, repair/repeat and modeled schema-9 name-ID fixture migration pass. This fixture models the old identity seam; it is not a replay of every old release semantic. |
+
+Former blanket overloaded-constructor rejection is superseded by this accepted
+signature profile. Existing ownership rejection tests now use duplicate equivalent
+signatures; they retain their exact-owner and corruption assertions. Delegation
+keeps the actual body owner without inventing a direct delegation call. The
+reference-return callable omission remains a separate INC-QML-29 gap under
+REQ-QML-018-AC02/AC03 until its ordinary producer regression passes.
+
+Source integrity additionally maps REQ-QML-008-AC02/AC03 to
+`tests/test_cpp_overload_source_integrity.py::test_req_qml008_ac03_out_of_file_constructor_transport_cannot_bind`
+and the exact containing-class/corrupt-file authority controls in that module.
+The fourteen collected cases pass against the production binder. Source-size
+authority does not turn incomplete SDK include evidence into type authority.
+
+## Explicit emission observations
+
+| Acceptance IDs | Exact ordinary tests | State |
+| --- | --- | --- |
+| REQ-QML-016-AC01 | `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_explicit_unknown_sites_do_not_admit_bare_calls`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_long_comment_crlf_bom_and_unicode_keep_original_spans`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_explicit_annotation_owns_emission_mechanism`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_override_cannot_recover_annotation_by_prefix`; `tests/test_qt_explicit_emissions.py::test_req_qml016_ac01_computed_receiver_keeps_full_explicit_source_site` | Local known/unknown/override/mechanism/original-byte source acceptance passes; computed receiver target remains unavailable. |
+| REQ-QML-016-AC04 | `tests/test_qt_explicit_emissions.py::test_req_qml016_ac04_unknown_emissions_reload_as_owned_unresolved_sites`; `tests/test_qt_explicit_emission_updates.py::test_req_qml016_ac04_header_rename_remove_restore_and_marker_edits_preserve_observations`; `tests/test_qt_explicit_emission_updates.py::test_req_qml016_ac04_failed_explicit_refresh_retains_products_and_retries`; `tests/test_qt_explicit_emission_updates.py::test_req_qml016_ac04_readonly_declaration_removal_preserves_graph_and_explicit_retry` | Actual facade/build/reload/affected, cold/warm/manual/watch and real write-failure/repaired-repeat parity pass; Reviewed installed suite passes; final profile and limits appear below. |
+
+## Project and header adoption
+
+| Acceptance IDs | Exact ordinary tests | State and limits |
+| --- | --- | --- |
+| REQ-QML-018-AC01 | `tests/test_qt_qmake_adoption.py::test_req_qml018_ac01_pwd_paths_preserve_current_file_and_original_token_spans`; `tests/test_qt_qmake_adoption.py::test_req_qml018_ac01_unrelated_conditional_build_settings_do_not_hide_module`; `tests/test_qt_qmake_adoption.py::test_req_qml018_ac01_relevant_conditions_remain_source_owned_uncertainty`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac01_direct_facade_project_index_and_reload_preserve_pwd_roles`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac01_metadata_only_pwd_edit_removal_matches_clean_updates` | Local reader/facade/project-index/reload/query and cold/warm/manual/watch pass. PWD is current-file literal evidence; import hints never extend discovery or configured roots. |
+| REQ-QML-018-AC06 | `tests/test_qt_qmake_adoption.py::test_req_qml018_ac06_scope_depth_is_bounded`; `tests/test_qt_qmake_adoption.py::test_req_qml018_ac06_multibyte_path_transport_limit_is_explicit`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac06_rejected_metadata_preserves_products_and_existing_cache`; `tests/test_qt_qmake_adoption_integration.py::test_req_qml018_ac06_readonly_publication_after_metadata_edit_retains_and_retries`; `tests/test_qt_header_updates.py::test_req_qml018_ac06_header_refresh_denial_preserves_then_recovers` | Local conditional/expansion/root escape, parser and actual read-only publication retention/repaired-repeat pass. Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-018-AC07 | `tests/test_qt_header_adoption.py::test_req_qml018_ac07_whitespace_header_uses_cpp_source_authority`; `tests/test_qt_header_adoption.py::test_req_qml018_ac07_inert_cpp_markers_keep_c_dispatch`; `tests/test_qt_header_adoption.py::test_req_qml018_ac07_objective_c_retains_dispatch_priority`; `tests/test_qt_header_adoption.py::test_req_qml018_ac07_header_prefix_budget_and_digit_separators`; `tests/test_qt_header_updates.py::test_req_qml018_ac07_header_dispatch_mutation_matches_cold_and_warm` | Local lexical/plain-C/Objective-C/original-byte, bounded prefix and extractor-switch lifecycle pass. Header classification grants no native SDK authority. |
+
+## Declared providers and reference callables
+
+| Acceptance IDs | Exact ordinary tests | State and limits |
+| --- | --- | --- |
+| REQ-QML-018-AC03 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_declared_expression_selects_backend_api`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_unknown_or_conflicting_factory_never_supplies_provider`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_conditional_provider_declaration_is_not_api_authority`; `tests/test_qt_declared_api_shapes.py::test_req_qml018_ac03_root_shape_must_match_literal_member_operator`; `tests/test_qt_declared_api_shapes.py::test_req_qml018_ac03_direct_producer_keeps_original_cv_shape_and_byte_position` | Exact source-owned API/shape/type/root evidence, malformed/unknown/conflicting/conditional/alias/operator rejection and no execution pass locally. |
+| REQ-QML-018-AC04 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_context_service_subscriptions_have_distinct_sites`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_qml_lexical_and_property_shadows_precede_context_provider`; `tests/test_qt_typed_provider_consumers.py::test_req_qml018_ac04_typed_subscriptions_survive_build_reload_and_query`; `tests/test_qt_context_implicit_subscriptions.py::test_req_qml018_ac04_native_parameters_own_nested_provider_and_callback_lookup`; `tests/test_qt_context_implicit_subscriptions.py::test_req_qml018_ac04_nearer_local_callback_keeps_explicit_authority`; `tests/test_qt_declared_proof_integrity.py::test_req_qml018_ac04_ac06_reloaded_notify_proof_retains_mapping_identity` | Source subscription/handler identities, real NOTIFY, original scopes, explicit/local/implicit binding and default/directed reload pass; delivery is not a calls edge. Affected direction closes separately in INC-QML-30. |
+| REQ-QML-018-AC05/AC06 | `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac05_cpp_qml_metadata_and_signal_updates_match_clean_rebuild`; `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac06_parser_and_resolver_failure_preserve_then_recover`; `tests/test_qt_declared_proof_integrity.py::test_req_qml018_ac06_typed_hop_rejects_altered_authoritative_declaration`; `tests/test_qt_declared_proof_integrity.py::test_req_qml018_ac06_typed_notify_revalidates_property_accessor_provenance` | Real source/configuration updates, cache/graph/cohort retention and repaired retry; independently corrupted canonical role/owner/file/span/type/accessor proof rejects. Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-008-AC02/AC03; REQ-QML-018-AC02/AC03 | `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_ac03_reference_return_has_canonical_callable`; `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_qualified_reference_declaration_and_definition_keep_one_callable`; `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_ac03_reference_provider_spans_and_consumers_survive_reload`; `tests/test_cpp_reference_returns.py::test_req_qml018_ac02_reference_fallback_requires_one_function_child` | Accepted grammar wrapper, ordinary controls, exact generic identity and original bytes; unsupported/missing/competing child authority rejects. Reference API profile preserves CV without runtime proof. |
+| REQ-QML-011-AC01/AC04; REQ-QML-018-AC02/AC03/AC06 | `tests/test_cpp_reference_return_updates.py::test_req_qml018_ac02_ac03_reference_factory_edits_keep_identity_and_drop_stale_provider_edges`; `tests/test_cpp_reference_return_updates.py::test_req_qml018_ac02_ac03_reference_admission_failure_preserves_and_repairs`; `tests/test_cpp_reference_return_updates.py::test_req_qml018_ac02_ac03_readonly_reference_edit_retains_products_and_retries` | Actual C++-only manual/watch, cold/warm, stale-edge removal, parser/late publication/real Windows read-only failure, repaired idempotent retry pass locally. |
+
+## Affected logical direction
+
+Callback correspondence additionally assigns REQ-QML-018-AC04/AC06 to
+`tests/test_qt_context_callback_integrity.py::test_req_qml018_ac04_ac06_callback_target_cannot_be_substituted`,
+`tests/test_qt_context_callback_integrity.py::test_req_qml018_ac04_ac06_local_callback_precedes_same_named_qml_function`,
+`tests/test_qt_context_callback_integrity.py::test_req_qml018_ac06_callback_borrowed_provenance_is_revalidated`
+and `tests/test_qt_context_callback_integrity.py::test_req_qml018_ac04_callback_lookup_retains_object_scope`.
+These exercise actual raw and default/directed build/JSON/reload, derived target
+substitution, borrowed source/span/scope corruption and local/qualified controls.
+
+| Acceptance IDs | Exact ordinary tests | State and limits |
+| --- | --- | --- |
+| REQ-QML-018-AC04; REQ-QML-017-AC04; REQ-QML-011-AC01 | `tests/test_qt_affected_direction.py::test_native_provider_affected_uses_accepted_serialized_direction`; `tests/test_qt_affected_direction.py::test_logical_member_seed_finds_caller_without_reporting_seeded_method`; `tests/test_qt_affected_direction.py::test_legacy_edges_without_direction_keep_existing_traversal`; `tests/test_qt_affected_direction.py::test_logical_direction_depth_cycles_and_repeated_query_preserve_graph` | Local real build/JSON reload/CLI, directed/undirected insertion reversal, legacy, depth/filter/source-site and graph-preservation proof passes. |
+| REQ-QML-018-AC06; REQ-QML-011-AC04 | `tests/test_qt_affected_direction.py::test_corrupt_direction_cannot_fall_back_to_physical_incoming`; `tests/test_qt_affected_direction.py::test_qt_owner_promotion_rejects_partial_or_foreign_direction`; `tests/test_qt_affected_direction.py::test_typed_reload_preserves_corrupt_dependency_direction_for_rejection`; `tests/test_qt_affected_direction.py::test_typed_reload_preserves_corrupt_owner_direction_for_rejection`; `tests/test_qt_affected_direction.py::test_boolean_marker_cannot_coerce_integer_endpoint_identity`; `tests/test_qt_affected_direction.py::test_parallel_edges_keep_valid_filtered_mechanism_after_invalid_direction` | Complete/partial/foreign/ill-typed/directed-contradictory corruption cannot authorize a dependency or source-owner link. Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-018-AC04/AC06; REQ-QML-011-AC01/AC04 | `tests/test_qt_affected_direction_updates.py::test_native_affected_direction_cold_warm_update_removal_recovery_and_repeat` | Actual manual/watch source edits, removal, repair and cold/warm/repeated parity pass locally. |
+
+## JSON direction integrity and combined adoption
+
+| Acceptance IDs | Exact ordinary tests | State and limits |
+| --- | --- | --- |
+| REQ-QML-013-AC01/AC03; REQ-QML-018-AC04/AC06 | `tests/test_qt_json_direction.py::test_json_direction_rejection_preserves_previous_bytes_and_safe_diagnostic`; `tests/test_qt_json_direction.py::test_json_directed_contradiction_rejects_before_first_file_exists`; `tests/test_qt_json_direction.py::test_native_source_proof_export_reload_consumer_survives_rejection_and_repair`; `tests/test_qt_json_direction.py::test_undirected_reversed_storage_retains_real_qml_dependency_direction` | Local real writer/native proof/reload/affected, force/rejection/prior-output/repair and accepted insertion reversal pass. |
+| REQ-QML-013-AC01/AC03; REQ-QML-018-AC06 | `tests/test_qt_json_direction.py::test_external_direction_rejection_precedes_file_or_database_connection`; `tests/test_qt_json_direction.py::test_external_bidirectional_wrapper_honors_logical_pair_but_json_requires_native_direction`; `tests/test_qt_json_direction.py::test_legacy_unmarked_edges_and_valid_self_pairs_preserve_native_identity` | External preflight/call boundary and compatible logical wrapper behavior pass; no live database engine proof is claimed. |
+| REQ-QML-011-AC04; REQ-QML-018-AC06 | `tests/test_qt_json_direction_updates.py::test_rejected_direction_clustered_publication_retains_products_cache_and_repairs` | Actual clustered manual/watch serializer rejection preserves seven durable products and real source-cache bytes; repair equals clean source contracts and repeat is byte-identical. |
+| REQ-QML-018-AC01–AC05/AC07 | `tests/test_qt_combined_adoption.py::test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates` | Public combined CMake/qmake, whole root/safe application subroot, configured module, inherited emission/exact constructor/typed API/subscription, source cold/warm/manual/watch and QML-only callback edit parity pass locally. Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-018-AC06 | `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_rejection_retains_cache_products_and_retries`; `tests/test_qt_combined_adoption_safety.py::test_req_qml018_ac06_combined_readonly_destination_preserves_then_retries` | Missing parser, malformed source, unsafe import root and actual Windows read-only graph replacement retain prior products/cache and succeed on corrected retry/idempotent repeat. Reviewed installed suite passes; final profile and limits appear below. |
+
+## First raw-update repeat
+
+| Acceptance IDs | Exact ordinary tests | State and limits |
+| --- | --- | --- |
+| REQ-QML-011-AC01; REQ-QML-018-AC05/AC06 | `tests/test_qt_initial_repeat.py::test_cold_combined_no_cluster_repeat_keeps_bytes_mtime_and_changed_source`; `tests/test_qt_initial_repeat.py::test_cold_generic_no_cluster_repeat_does_not_rewrite_first_graph` | Actual cold CLI/watch, CMake/qmake, whole-root/subroot, bytes/mtime repeat and subsequent real edit pass locally; Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-011-AC01/AC04; REQ-QML-018-AC06 | `tests/test_qt_initial_repeat.py::test_known_absent_and_empty_run_lists_compare_equal_without_mutation`; `tests/test_qt_initial_repeat.py::test_nonempty_or_wrong_type_run_lists_remain_observable`; `tests/test_qt_initial_repeat.py::test_unknown_metadata_and_source_facts_still_compare_different`; `tests/test_qt_initial_repeat.py::test_real_raw_publication_failure_retains_prior_then_recovers` | Bounded normalization, unchanged input, meaningful/malformed/source proof controls and real staged replacement rejection/repair pass locally. |
+
+## C++ file proof and candidate preflight
+
+| Acceptance | Exact automated coverage | Current evidence |
+| --- | --- | --- |
+| REQ-QML-020-AC01/AC03; REQ-QML-008-AC02 | `tests/test_qt_cpp_file_roles.py::test_req_qml020_ac01_ac03_cpp_file_transport_retains_exact_file_identity`; `tests/test_qt_cpp_file_roles.py::test_req_qml008_ac02_cpp_file_role_preserves_separate_constructor_type_authority` | Current, legacy, incomplete, fresh and reloaded file proof; complete-record constructor authority stays separate. Local shared suite passes; Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-020-AC02 | `tests/test_qt_cpp_file_roles.py::test_req_qml020_ac02_cpp_file_transport_cannot_authorize_corrupted_file_role`; `tests/test_qt_cpp_file_roles.py::test_req_qml020_ac02_cpp_file_joins_read_no_source_and_execute_no_corpus` | Actual borrowed transport at both joins rejects corruption without mutating, rereading or executing the corpus. Local shared suite passes; Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-013-AC03; REQ-QML-011-AC04; REQ-QML-018-AC06 | `tests/test_qt_update_direction_integrity.py::test_actual_corrupt_candidate_refuses_shortcut_publication_and_repairs`; `tests/test_qt_update_direction_integrity.py::test_complete_reverse_pair_remains_accepted_on_undirected_storage` | 68 local manual/watch raw/clustered force/default failure/recovery/control cases pass; Reviewed installed suite passes; final profile and limits appear below. |
+
+## Prior import and repeated event ownership
+
+| Acceptance | Exact ordinary coverage | Evidence |
+| --- | --- | --- |
+| REQ-QML-020-AC03; REQ-QML-011-AC01/AC04 | `tests/test_qt_cpp_file_role_updates.py::test_req_qml020_ac03_cpp_transport_edits_and_removal_preserve_exact_membership`; `tests/test_qt_cpp_file_role_updates.py::test_req_qml011_ac04_cpp_deleted_import_keeps_still_referenced_external_endpoint`; `tests/test_qt_cpp_file_role_updates.py::test_req_qml011_ac04_cpp_import_cleanup_preserves_same_label_source_and_semantic_roles`; `tests/test_qt_cpp_file_role_updates.py::test_req_qml011_ac04_cpp_import_cleanup_requires_exact_prior_source_proof` | Manual/watch cold/warm deletion/restoration and exact raw prior-proof authority; source passes; Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-018-AC06; REQ-QML-011-AC04 | `tests/test_qt_cpp_file_role_updates.py::test_req_qml018_ac06_cpp_orphan_cleanup_failed_publication_preserves_products_and_retries`; `tests/test_qt_cross_event_occurrences.py::test_req_qml018_ac06_cross_event_publication_failure_retains_prior_and_retries` | Actual Windows OS rejection, prior products and nonempty source cache retained, corrected retry/repeat pass; Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-016-AC02/AC04; REQ-QML-017-AC02/AC04 | `tests/test_qt_cross_event_occurrences.py::test_req_qml016_ac02_ac04_cross_language_occurrences_keep_own_transport` | 1–16 mixed repeated connect/disconnect sites, both graph modes and QML/C++ directions, original span and bounded transport through production assembly/reload pass. |
+| REQ-QML-011-AC03/AC04; REQ-QML-018-AC06 | `tests/test_qt_cross_event_occurrences.py::test_req_qml011_ac04_cross_event_edits_removal_and_repeat_match_clean`; `tests/test_qt_cross_event_occurrences.py::test_req_qml011_ac03_event_policy_refreshes_unchanged_source` | Actual C++/QML edits, stale removal, repair/full parity/unrelated Python and old-checkpoint refresh pass; Reviewed installed suite passes; final profile and limits appear below. |
+
+## Bridge mechanisms and first minted provenance
+
+| Acceptance | Exact ordinary coverage | Source result |
+| --- | --- | --- |
+| REQ-QML-018-AC04/AC06; REQ-QML-017-AC02/AC04; REQ-QML-010-AC03/AC04 | `tests/test_qt_bridge_mechanism_integrity.py::test_source_mechanisms_survive_builder_export_and_reload`; `tests/test_qt_bridge_mechanism_integrity.py::test_source_mechanism_substitution_is_rejected_before_graph_assembly`; `tests/test_qt_bridge_mechanism_integrity.py::test_context_access_revalidates_original_read_or_call`; `tests/test_qt_bridge_mechanism_integrity.py::test_endpoint_role_cannot_be_changed_to_repair_relation`; `tests/test_qt_bridge_mechanism_integrity.py::test_cross_language_flow_cannot_be_reversed_by_metadata`; `tests/test_qt_bridge_mechanism_integrity.py::test_reverse_reflection_repeats_original_supported_operation` | 166 new cases pass across 18 mechanisms; typed `uses` paths cannot bypass assembly proof. Reviewed installed suite passes; final profile and limits appear below. |
+| REQ-QML-011-AC01/AC04; REQ-QML-018-AC05/AC06 | `tests/test_qt_publication_stub_origin.py::test_first_raw_stub_has_explicit_semantic_origin`; `tests/test_qt_publication_stub_origin.py::test_first_raw_import_repeat_preserves_bytes_mtime_and_genuine_edit`; `tests/test_qt_publication_stub_origin.py::test_existing_authored_endpoint_is_not_stamped_by_minting`; `tests/test_qt_publication_stub_origin.py::test_first_stamped_cpp_import_deletion_restoration_and_repeat`; `tests/test_qt_publication_stub_origin.py::test_new_stub_replacement_failure_retains_cohort_cache_then_retries` | 22 new cold manual/watch C++/Python, origin-preservation and injected replacement-failure/retry cases pass. Actual Windows denial is separately mapped above. Reviewed installed suite passes; final profile and limits appear below. |
+
+## Final adoption delivery
+
+Profile: Windows x64, Python 3.12.14, tree-sitter 0.25.2,
+tree-sitter-language-pack 0.11.0; Qt 6 source inputs with CMake and qmake. The
+reviewed tree is `9f0ae4ea75f47539df8ec8a66f1493b9e7f52b2f`. All 179 Python payloads
+match the built wheel and installed package; the isolated installed selection
+passes 3,024 tests with 36 historical optional/platform skips. None of the newly
+added regression cases skips. Exact commands, digest and full-gate exceptions are
+in [validation](../docs/qt-qml/VALIDATION.md#final-adoption-delivery).
+
+| Criterion | Independently assigned production evidence | Current applicability and result |
+| --- | --- | --- |
+| REQ-QML-018-AC01 | Project/header table above: direct reader, facade, project index, reload and actual metadata-only updates; combined test and installed CMake/qmake whole/subroot CLI profiles | Locally Verified; arbitrary expansion/build execution excluded |
+| REQ-QML-018-AC02 | Native syntax/cache upgrade and reference-return tables above: hand-checked original-byte facts, malformed/inert/generic controls and source/installed consumers | Locally Verified; unsupported relationships stay unresolved |
+| REQ-QML-018-AC03 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_declared_expression_selects_backend_api`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac03_unknown_or_conflicting_factory_never_supplies_provider`; declaration-shape and producer-integrity modules mapped above, executed in source and installed selections | Locally Verified; accepted pointer/lvalue-reference declarations only, no factory execution or runtime conversion proof |
+| REQ-QML-018-AC04 | `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_typed_service_call_and_property_are_source_owned`; `tests/test_qt_typed_provider_adoption.py::test_req_qml018_ac04_context_service_subscriptions_have_distinct_sites`; `tests/test_qt_typed_provider_consumers.py::test_req_qml018_ac04_typed_subscriptions_survive_build_reload_and_query`; affected direction, callback/mechanism proof and repeated-site tables above | Locally Verified in source and installed selections; no inferred runtime delivery |
+| REQ-QML-018-AC05 | `tests/test_qt_combined_adoption.py::test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates`; `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac05_cpp_qml_metadata_and_signal_updates_match_clean_rebuild`; four actual isolated installed CLI profiles, each initial/repeat exit 0 and identical graph bytes | Locally Verified; hand-checked CMake/qmake whole/subroot facts plus cold/warm/manual/watch mutation parity. Other hosted OS/Python cells unexecuted |
+| REQ-QML-018-AC06 | Both combined safety tests above; `tests/test_qt_typed_provider_updates.py::test_req_qml018_ac06_parser_and_resolver_failure_preserve_then_recover`; JSON/preflight/repeat/orphan/publication failure tables above; actual Windows read-only denial complements injected replacement failure | Locally Verified in source and installed selections; prior products and nonempty successful cache, diagnostics, repair/repeat, no execution and bounded roots. Missing optional parser is separately exercised, not counted as a skip |
+| REQ-QML-018-AC07 | Header-discovery/update table above: whitespace/BOM/CRLF/Unicode, comment/string/C/inconclusive/Objective-C controls and actual header edits; source and installed selections | Locally Verified; headers without accepted C++ evidence keep established dispatch |
+
+INC-QML-11 inherited endpoints and INC-QML-15 exact constructor identity retain
+their existing individual assignments above and pass in the reviewed installed
+selection. The final full source command records 8,630 passed, 59 failed,
+178 skipped and three warnings; the exact 59 failure identities equal baseline
+5c0f2ca. Full Pyright has no added diagnostic identities but remains failed.
+Current hosted platform jobs, live service delivery and native browser/device
+procedure have not executed for this source. A global Verified or release-ready
+status cannot be inferred from the bounded local evidence.
+
+## Current-upstream integration — INC-QML-49
+
+Source checkpoint: 65103f8; incoming v8:35adf43/package 0.9.76. Corrected integration
+proof passes at the recorded 8b6c9d2 checkpoint below. The plan's acceptance matrix identifies affected IDs before
+implementation; concrete tests, commands, individual outcomes, baseline gaps and
+reviewed source/base/checkout identity are recorded here as they complete.
+Previous hosted results remain immutable checkpoints. Old skips or aggregate
+counts cannot establish the new joint-boundary criteria.
+
+The frozen native profile is Windows x64, Python 3.12.14, package 0.9.76,
+tree-sitter 0.25.2/language-pack 0.11.0, AST schema 13 and Qt policy 22.
+The ordinary joint selection executes 99 cases: **83 passed, sixteen explicit
+POSIX traversal exclusions, zero failures**. A final context-only correction is
+verified separately by all six native admission cases, with their exact code,
+empty unavailable context, fragmentwise backend-body absence and repaired repeat
+assertions. POSIX link/`..` controls require actual POSIX runner execution.
+
+| Affected acceptance IDs | Exact production tests and profile result |
+| --- | --- |
+| REQ-QML-002-AC02/AC03/AC04; REQ-QML-020-AC01 | `tests/test_upstream_qt_discovery_integration.py::test_req_qml002_ac02_ac03_detect_and_collect_share_named_metadata_and_copy_exclusions`; `tests/test_upstream_qt_discovery_integration.py::test_req_qml002_ac03_ac04_followed_metadata_keeps_lexical_scope_and_rejects_foreign_input`; direct cold/warm, real manual/watch and malformed-metadata cases in the same module. Eleven native cases pass; default/contained-follow, ignores and installed-copy exclusion remain production boundaries. |
+| REQ-QML-003-AC04; REQ-QML-011-AC03/AC04; REQ-QML-018-AC05/AC06 | `tests/test_upstream_qt_cache_integration.py::test_req_qml003_ac04_qml018_ac05_old_namespaces_miss_and_refresh`; `tests/test_upstream_qt_cache_integration.py::test_req_qml003_ac04_qml018_ac05_mixed_cold_warm_serialized_facts`; `tests/test_upstream_qt_cache_integration.py::test_req_qml011_ac03_ac04_qml018_ac06_upgrade_failure_retains_repairs_repeats`. Six native cases pass, with old schemas 5/12 and both package versions, receiver-shadow/Qt fact parity, semantic byte retention and actual replacement failure/recovery. |
+| REQ-QML-010-AC02; REQ-QML-018-AC04/AC06 | `tests/test_upstream_qt_export_integration.py::test_req_qml_010_ac02_original_parallel_links_retain_qml_direction`; `tests/test_upstream_qt_export_integration.py::test_req_qml_018_ac06_preserved_invalid_link_refuses_replacement`; `tests/test_upstream_qt_export_integration.py::test_req_qml_018_ac04_real_recluster_keeps_parallel_and_qml_links`. Ten native cases pass, preserving distinct generic mechanisms, actual QML direction/provenance, prior bytes/mtime, repaired repeat and real offline reclustering. |
+| REQ-QML-010-AC03; REQ-QML-018-AC04 | `tests/test_upstream_qt_path_integration.py::test_req_qml_010_ac03_qualified_native_path_preserves_direction`; `tests/test_upstream_qt_path_integration.py::test_req_qml_018_ac04_duplicate_qualified_target_refuses_guess`. Three native cases pass through the real CLI, qualified file/symbol and exact-ID selection, same-name decoys, explicit duplicate rejection, directed/undirected controls and unchanged durable input. |
+| REQ-QML-003-AC01/AC02/AC04; REQ-QML-011-AC01/AC04; REQ-QML-018-AC02 | `tests/test_upstream_qt_shared_integration.py::test_req_qml003_ac01_ac02_ac04_union_and_qt_native_cold_warm_reload`; `tests/test_upstream_qt_shared_integration.py::test_req_qml011_ac01_ac04_union_edit_removes_stale_member_keeps_native_endpoint`. Four native CMake/qmake cases pass with original spans, distinct same-named C++ union/native method ownership, real edits, stale removal and repeated output. |
+| REQ-QML-020-AC01/AC02/AC03; REQ-QML-003-AC04; REQ-QML-011-AC03/AC04; REQ-QML-018-AC05/AC06 | `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac02_typed_producers_keep_distinct_alias_facts_and_original_spans`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac02_followed_metadata_facade_keeps_every_owner_cold_warm_and_reload`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac03_typed_alias_updates_remove_stale_facts_and_match_full_rebuild`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac03_alias_read_failure_retains_cohort_cache_and_repairs`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac02_membership_rejects_another_walked_owner_of_same_physical_input`; `tests/test_qt_followed_metadata_alias.py::test_req_qml020_ac03_policy21_alias_products_refresh_retain_and_recover`. All 43 native cases pass, including BOM/CRLF/Unicode spans, nested owner/target references, actual junction/root aliases, foreign inputs, malformed lexical context and native short leaf spelling. |
+| REQ-QML-012-AC01/AC02/AC04; REQ-QML-020-AC02 | `tests/test_qt_source_identity_admission.py::test_req_qml020_ac02_native_admission_failure_keeps_its_code_and_recovers`. Six native production-reader cases pass after the final correction. `tests/test_qt_source_identity_admission.py::test_req_qml020_ac02_dotdot_input_keeps_actual_physical_target_and_containment` has sixteen Windows exclusions because actual POSIX physical link/`..` traversal is its test boundary; all sixteen cases pass individually in each recorded Linux lane below. |
+| REQ-CORE-004-AC02 | `tests/test_extract.py::test_python_external_calls_survive_real_incremental_context` passes in the corrected native profile; all ten upstream extraction assertions remain structurally unchanged. The exact case is included in native hosted selection. |
+
+The joint command is:
+
+```text
+python -X utf8 -m pytest tests/test_upstream_qt_cache_integration.py tests/test_upstream_qt_discovery_integration.py tests/test_upstream_qt_export_integration.py tests/test_upstream_qt_path_integration.py tests/test_upstream_qt_shared_integration.py tests/test_qt_followed_metadata_alias.py tests/test_qt_source_identity_admission.py -q --tb=short -rs --junitxml=joint.xml
+```
+
+Adjacent profiles pass 601 upstream/shared cases with eleven capability skips,
+172 cache/stat-index/Python/Qt cases with eight file-symlink skips, and 271
+export/path/direction cases with no skips. Counts overlap and are not summed.
+Upstream multi-root stat-index warnings are retained. The original collector
+leaks installed copies under both profiles; both immutable parent cache modules
+reuse an old namespace; the old export loses a parallel relation and old path
+lookup fails qualified identity. The prior full producer and current pre-fix
+typed reader omit walked metadata; twenty ordinary alias cases fail before the
+correction. Replays use actual immutable modules with current support imports,
+not claims of full historical dependency equivalence or a checkout swap.
+
+The corrected source wheel contains **185 Python payloads**, each byte-identical
+to source and both isolated QML/core installs. Its SHA-256 is
+`870a56e35e3a18afde2f7c13811e6e3fa8af53529f485a4399521133046d9d01`.
+Both isolated installed smokes and all three reviewed-artifact tests pass.
+Production and original coverage bytes remain unchanged during artifact proof.
+The preliminary wheel precedes the final diagnostic correction and is not used
+as final acceptance. Hosted wheels retain their own independently recorded digests.
+
+Whole Ruff passes. All five skillgen validators pass, including 134 generated
+artifacts. Frozen explicit-interpreter Pyright remains **605 errors, zero warnings**;
+all new integration modules have zero diagnostics. Four errors move with an
+upstream responsibility transfer and one Dart OptionalSubscript is reproduced
+from the immutable incoming source. This is a failed existing contribution gate,
+not successful typing. The final 197-distribution inventory changes only the
+Graphify package version from the historical profile. No assertion, required
+check, protection or historical commit is weakened or rewritten. Advisory
+security and physical application/device procedures remain separate gaps.
+
+Normal fork PR proof targets the exact incoming 35adf43 base on
+`codex/qml-upstream-base`; the integration branch remains in the canonical
+workspace. All prior PRs and the verified 65103f8 branch are preserved. No
+upstream PR, merge, deployment or default-branch update is performed. Terminal
+current head/base/checkout identities, cases, artifacts and cleanup remain the
+hosted exit gate under REQ-CORE-004-AC01–AC04.
+
+### First integrated hosted checkpoint — dbc4859
+
+Draft [PR 7](https://github.com/SlinkyRamey/graphify/pull/7) binds source
+`dbc4859f454f9478ac9596559a75efef5f10664d`, base
+`35adf432b9d50f6f3d530ab5d7ec316819ef081c` and actual synthetic checkout
+`db0b4dd0829082dca1e222bc90a85571871fb4bd`.
+[CI 37287514274](https://github.com/SlinkyRamey/graphify/actions/runs/37287514274)
+fails the stale platform selector guard in Python 3.14; the other three full
+Linux jobs are cancelled, so those profiles remain incomplete.
+The completed Linux 3.14 profile reports 9,354 passed, one failed and 121 skipped;
+all 34 joint cases, 40 applicable alias cases, sixteen real POSIX link/`..`
+controls and five service/wheel cases pass individually. All four retained
+Linux wheel payloads match the 185 reviewed source modules, and every disposable
+service container is confirmed absent, including the cancelled lanes. The
+cancelled suites have no completed JUnit/pytest-exit/integrity evidence.
+[Wheel matrix 37287514235](https://github.com/SlinkyRamey/graphify/actions/runs/37287514235)
+exposes the same guard in native source lanes and a distinct native metadata
+fixture seam. INC-QML-51/52 own their corrections; this is a failed integration
+checkpoint, not hosted release acceptance.
+All four macOS source lanes report one failed, 2,360 passed and 64 skipped;
+each Windows source lane reports two failed, 2,404 passed and nineteen skipped.
+The four Ubuntu wheel lanes each pass three artifact tests. All 24 isolated
+optional/core installed smokes pass. Those wheel jobs retain no per-case JUnit,
+wheel-digest or source/dependency artifact; native platform tests import the
+checkout and remain source evidence distinct from the installed smokes.
+
+The [native Windows job](https://github.com/SlinkyRamey/graphify/actions/runs/37287514274/job/111689834160)
+passes 923 cases with 37 explicit exclusions and pytest exit zero. All 34 joint
+integration, 43 followed-metadata alias, six native failure/recovery and exact
+INC-CORE-10 cases pass individually. The exclusions retain 21 previous cases
+plus sixteen actual POSIX link/`..` controls. Artifact 11335540466's API digest
+and downloaded ZIP SHA-256 both equal
+`1ce081338cf8963694e37b52b66a949c3149c3ceae3a7ca43b2b158e2815868c`.
+The retained preflight proves both admitted Bash/sh consumers, Python 3.12.10
+and Node 24.19.0; checkout, tracked inputs and all 197 dependencies are unchanged.
+The skill job passes all five validators and 134 artifact checks. Advisory
+security has an API-success conclusion but both raw commands return exit one:
+Bandit reports four high, eight medium and 112 low findings; pip-audit reports
+fifteen raw vulnerability rows across three packages. These are failed advisory
+commands, not a clean security scan.
+
+### Platform selection guard correction — INC-QML-51
+
+Affected assignments: REQ-QML-001-AC01/AC03, REQ-QML-014-AC01/AC03/AC04 and
+REQ-CORE-004-AC03 map to
+`tests/test_qml_platform_matrix.py::test_wheel_runner_keeps_optional_core_and_isolated_offline_smoke_boundaries`.
+The original production runner correctly admits the new upstream/Qt modules;
+its guard incorrectly requires the former two-prefix literal. Local original
+platform execution reports one passed/one failed test. Corrected execution of
+both platform tests passes on native Python 3.12.14, with zero Ruff/Pyright
+diagnostics. The guard now compiles only the trusted runner's actual selection
+expression and exercises real temporary files in native and artifact-only
+modes, retaining every install/isolation/offline assertion. It rejects unrelated
+language modules, runner helpers and wrong extensions; deterministic selected
+names include the joint regression prefix.
+The owned glob seam reverses actual fixture entries; stable ordering therefore
+depends on the production sort rather than the host's directory enumeration.
+
+Executed command:
+
+```text
+python -X utf8 -m pytest tests/test_qml_platform_matrix.py tests/test_qml_wheel_artifact.py -q --tb=short --junitxml=platform.xml
+```
+
+Without the reviewed-wheel variable this command passes two tests and explicitly
+skips three artifact tests; that run is guard evidence only. The retained reviewed
+wheel procedure and corrected hosted revision own artifact acceptance.
+The same command with the final reviewed-wheel variable passes all five tests
+without skips. The wheel remains byte-identical to the runtime checkpoint;
+these corrections change test evidence only.
+
+### Native metadata-growth fixture correction — INC-QML-52
+
+REQ-QML-002-AC03, REQ-QML-012-AC01/AC04 and REQ-QML-014-AC02/AC03/AC04 map to
+`tests/test_qt_metadata_boundaries.py::test_cmake_scope_limit_and_source_file_growth_are_rejected`.
+The original fake stat carries only size and causes native identity admission
+to reject before the intended read-growth boundary. Correcting the stat witness
+must retain native mode/device/inode and every available non-size stat field;
+the original cmake_scope_limit and metadata_size_limit assertions remain
+authoritative. The original ordinary case fails on native Python 3.14.7 with
+native_source_spelling_unavailable before the intended read boundary. After
+the fixture correction all 35 module cases pass without skips on native
+3.10.21, 3.12.14, 3.13.15 and 3.14.7. The fixture checks its understated size,
+every real non-size stat field and actual same-file identity, retaining the
+original two rejection assertions. It measures 99 physical lines. Source
+runtime, dependencies and original coverage are unchanged; corrected hosted
+source outcomes pass in the verified integrated checkpoint below.
+
+Executed per-interpreter command:
+
+```text
+python -X utf8 -m pytest tests/test_qt_metadata_boundaries.py -q --tb=short --junitxml=metadata.xml
+```
+
+## Verified integrated hosted checkpoint — 8b6c9d2
+
+Draft [PR 7](https://github.com/SlinkyRamey/graphify/pull/7) source
+`8b6c9d2bd4a89377829cb09c0737744aebccfb97` targets exact incoming upstream base
+`35adf432b9d50f6f3d530ab5d7ec316819ef081c`. Both normal `pull_request` workflows
+test actual synthetic checkout `0863b2b7e5c63a27b6d5185490b8b610cc997424`;
+API parent identity and each job's retained checkout identity agree.
+[CI 37289566337](https://github.com/SlinkyRamey/graphify/actions/runs/37289566337)
+and [QML wheel 37289566340](https://github.com/SlinkyRamey/graphify/actions/runs/37289566340)
+both conclude success on attempt one. All nineteen validation jobs pass.
+The prior dbc4859 failures and cancelled profiles remain separate evidence.
+
+| Profile | Passed | Explicit exclusions | Actual evidence boundary |
+| --- | ---: | ---: | --- |
+| Full Ubuntu / Python 3.10.22 | 9,356 | 120 | Source suite, JUnit, pytest exit zero, retained identity/integrity and cleanup |
+| Full Ubuntu / Python 3.12.3 | 9,355 | 121 | Same source/retention boundary |
+| Full Ubuntu / Python 3.13.16 | 9,355 | 121 | Same source/retention boundary |
+| Full Ubuntu / Python 3.14.8 | 9,355 | 121 | Same source/retention boundary |
+| Focused native Windows / Python 3.12.10 | 923 | 37 | Actual Cmd/PowerShell/Bash/sh consumers, JUnit, uploaded report and input/dependency identity |
+| Four Windows QML lanes / Python 3.10/3.12/3.13/3.14 | 2,406 each | 19 each | Checkout source profiles and separate neutral isolated installed smokes |
+| Four macOS QML lanes / Python 3.10/3.12/3.13/3.14 | 2,361 each | 64 each | Checkout source profiles and separate neutral isolated installed smokes |
+| Four Ubuntu wheel lanes / Python 3.10/3.12/3.13/3.14 | 3 each | 0 | Reviewed-artifact contracts and separate neutral isolated installed smokes |
+
+Every Linux lane individually passes all 128 applicable integration assignments:
+34 joint upstream/Qt, 40 alias, sixteen real POSIX link/`..`, two platform guards,
+35 metadata-boundary and the exact INC-CORE-10 subprocess case. Nine new
+Windows-only native/short-leaf controls retain platform skips. The five actual
+FalkorDB/wheel cases also pass. The native Windows artifact individually passes
+all 34 joint, 43 alias, six native-admission and the same subprocess case.
+Its 37 exclusions are the prior 21 plus sixteen POSIX-only controls. Full skip
+inventories retain their actual reasons; version-specific `tomli`, exhausted
+bundle-fallback and absent optional graspologic comparison controls are not
+relabelled as passes. The native partition test still executes independently.
+
+All five source/native archives have downloaded SHA-256 equal to the GitHub API
+digest. Linux artifacts are 11336600508 (3.10), 11336267743 (3.12), 11336132399
+(3.13) and 11335669170 (3.14). The native artifact 11336246160 digest is
+`c280b9aa51311e5bf02874cc90f0eea7bbeb54e759c44e09c8290d8a5a7d36ef`.
+Its actual uploaded preflight proves both admitted Git Bash/sh consumers use
+the intended checkout Python and Node 24.19.0. Linux dependency inventories
+contain 185/195/174/174 distributions respectively; Windows contains 197.
+Each before/after inventory, tested checkout and tracked input identity is
+unchanged. All twelve Linux integrity flags pass. Real service readiness,
+test-graph deletion and absence of every job-owned container are confirmed;
+all four end-to-end install steps pass.
+
+Each full-source Linux wheel's 185 Python payloads equal reviewed source bytes
+and actual synthetic-checkout Git blobs. Their SHA-256 digests, in lane order
+3.10/3.12/3.13/3.14, are:
+
+- `1be83a0da3a89eb8824dd8a76b7445cba5a172f2cf52b76c5701dacf1ca375ca`
+- `cd6e12dd0d8131651300d06a9c3d0baad46bc788f78fdb258eda872799864291`
+- `3803242269198f47e6565dd6a9fe0ae0baecf5db323de430c4dc0afdb07661a6`
+- `02f3bf909e13aa4f4aff0fb402c9ecc6a8fc5d58038f61b8f37ab4302100485f`
+
+All 24 optional/core installed smokes pass from neutral directories with isolated
+interpreters. Windows/macOS platform tests import the checkout; their workflow
+retains aggregate logs, without per-case JUnit, wheel-digest or byte-attestation
+archives. Those limits are not filled by a source total or another job's wheel.
+Linux JUnit evidence establishes individual corrected guard and metadata cases;
+Linux/native JUnit establishes their applicable integration and subprocess cases.
+Skill generation passes five validators and 134
+artifact checks. Frozen local source typing still fails with 605 errors; new
+integration/changed fixture modules have zero scoped diagnostics.
+
+Advisory security still has successful job metadata but failed raw commands:
+four high/eight medium/112 low Bandit findings and fifteen dependency rows across
+three packages. All twelve printed medium/high owner functions match immutable
+upstream 35adf43 AST exactly; the 112 aggregate low findings are not individually
+classified. A clean security or typing contribution gate is not established.
+Physical browser/device/application interaction, Qt runtime effects, released
+upstream delivery and merging remain outside this hosted source profile.
+
+The final plan review finds no additional reproduced integration defect;
+INC-QML-53 and REQ-QML-022 remain unallocated. AST-only refresh after the source
+corrections completes with 23,247 nodes/53,843 edges and the known malformed Luau
+fixture warning. No graph commit stamp is inferred from that update command.
+This evidence completion changes documentation only. The later documentation
+head receives its own normal PR validation; this immutable checkpoint does not
+claim its unknown future head already passed. AI commit attribution and the
+original coverage artifact remain preserved; no upstream PR or merge is performed.
diff --git a/tests/audit/probe_qt_loader_forms.py b/tests/audit/probe_qt_loader_forms.py
new file mode 100644
index 0000000000..7031837fad
--- /dev/null
+++ b/tests/audit/probe_qt_loader_forms.py
@@ -0,0 +1,66 @@
+"""Opt-in INC-QML-20 audit of literal loader provenance, not runtime execution.
+
+Invoke explicitly with pytest: normal discovery excludes ``probe_*.py``. These
+strict assertions expose coverage omissions under REQ-QML-017-AC01/AC04; missing
+facts remain unresolved rather than linking a guessed component or property.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+
+
+@pytest.mark.parametrize("loader", ["engine_load", "engine_url_constructor", "component_load_url"])
+def test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance(tmp_path, loader):
+ """Equivalent literal loaders must preserve exact source and persisted joins."""
+ url = (tmp_path / "Main.qml").as_uri()
+ loader_expressions = {
+ "engine_load": f'engine.load(QUrl("{url}"))',
+ "engine_url_constructor": f'QQmlApplicationEngine engine(QUrl("{url}"))',
+ "component_load_url": f'component.loadUrl(QUrl("{url}"))',
+ }
+ expression = loader_expressions[loader]
+ forms = {
+ "engine_load": f'QQmlApplicationEngine engine; {expression}; auto root = engine.rootObjects().first();',
+ "engine_url_constructor": f'{expression}; auto root = engine.rootObjects().first();',
+ "component_load_url": f'QQmlEngine engine; QQmlComponent component(&engine); {expression}; auto root = component.create();',
+ }
+ source = 'void use() { ' + forms[loader] + ' root->property("value"); }'
+ result = analysis(tmp_path, {
+ "access.cpp": source,
+ "Main.qml": 'import QtQml\nQtObject { property int value: 1 }',
+ })
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+
+ # Check the source-owning loader occurrence, not a URL-less constructor or
+ # a generic C++ call that lacks special component provenance.
+ original = source.encode()
+ loaders = [node for node in sites(result, "qml_load")
+ if original[qt_metadata(node)["span"]["start_byte"]:
+ qt_metadata(node)["span"]["end_byte"]] == expression.encode()]
+ assert len(loaders) == 1, {
+ "loader": loader,
+ "recognized_operations": [qt_metadata(node)["operation"] for node in sites(result, "qml_load")],
+ }
+ metadata = qt_metadata(loaders[0])
+ assert metadata["status"] == "resolved"
+ assert metadata["target_id"] in reloaded
+ assert reloaded.has_edge(loaders[0]["id"], metadata["target_id"])
+ assert reloaded.edges[loaders[0]["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_load"
+
+ access = sites(result, "qml_access", "property")[0]
+ metadata = qt_metadata(access)
+ assert metadata["status"] == "resolved"
+ assert original[metadata["span"]["start_byte"]:metadata["span"]["end_byte"]] == b'root->property("value")'
+ assert reloaded.has_edge(access["id"], metadata["target_id"])
+ assert reloaded.edges[access["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_property_read"
diff --git a/tests/audit/probe_qt_native_type_shadowing.py b/tests/audit/probe_qt_native_type_shadowing.py
new file mode 100644
index 0000000000..254cfac71c
--- /dev/null
+++ b/tests/audit/probe_qt_native_type_shadowing.py
@@ -0,0 +1,221 @@
+"""Opt-in audit of native type authority through completed graph publication.
+
+Invoke this ``probe_`` file explicitly with pytest; normal ``test_*.py`` discovery
+excludes it. Unsafe alias assertions intentionally fail until lexical authority is
+implemented or the unsupported use is rejected. No C++/QML code executes. Unknown
+targets are acceptable; an unrelated same-spelling global class is never proof.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.affected import load_graph
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_qml_bridge import build_qt_qml_bridge
+
+DECLARATIONS = """class Sender : public QObject { Q_OBJECT
+signals: void changed(int value);
+};
+class Receiver : public QObject { Q_OBJECT
+public: void accept(int value) {}
+};
+class Other : public QObject { Q_OBJECT
+signals: void changed(int value);
+};
+"""
+ALIASES = ["using Sender = Other;", "typedef Other Sender;"]
+QML = "import Demo 1.0\nThing { onChanged: (value) => {} }\n"
+
+
+def persisted(root, source, *, qml=False):
+ """Synthetic bytes enter the real facade, assembly, JSON writer and loader."""
+ path = root / "events.cpp"
+ path.write_bytes(source.encode("utf-8"))
+ paths = [path]
+ if qml:
+ qml_path = root / "Main.qml"
+ qml_path.write_bytes(QML.encode("utf-8"))
+ paths.append(qml_path)
+ result = extract(paths, root=root, cache_root=root / "cache", parallel=False,
+ qml_import_roots=(".",))
+ assert not result.get("qml_failures") and not result.get("qt_failures")
+ output = root / "proof.json"
+ graph = build_from_json(result, root=root)
+ assert to_json(graph, {}, str(output), force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ reloaded = load_graph(output)
+ assert reloaded.is_directed()
+ # Evidence belongs to original file bytes, not normalized source/graph labels.
+ for node in payload["nodes"]:
+ metadata = qt_metadata(node) or qml_metadata(node)
+ if not metadata or not metadata.get("span"):
+ continue
+ data = (root / node["source_file"]).read_bytes()
+ span = metadata["span"]
+ start, end = span["start_byte"], span["end_byte"]
+ assert 0 <= start <= end <= len(data)
+ for prefix, offset in (("start", start), ("end", end)):
+ preceding = data[:offset]
+ assert span[prefix + "_row"] == preceding.count(b"\n")
+ assert span[prefix + "_column"] == len(preceding.rsplit(b"\n", 1)[-1])
+ return result, payload, reloaded
+
+
+def native_facts(result, kind):
+ return [node for node in result["nodes"] if qt_metadata(node).get("kind") == kind]
+
+
+def native_signal(result, class_name):
+ return next(node for node in native_facts(result, "member")
+ if qt_metadata(node).get("class_name") == class_name
+ and qt_metadata(node).get("raw_name") == "changed")
+
+
+def endpoint_links(payload, site):
+ """Role nodes retain logical direction after default undirected assembly."""
+ role_ids = {node["id"] for node in payload["nodes"]
+ if qt_metadata(node).get("kind") == "event_endpoint"
+ and qt_metadata(node).get("owner_id") == site["id"]}
+ return [edge for edge in payload["links"]
+ if edge["source"] in role_ids | {site["id"]}
+ and edge.get("context") in {"qt_connect_signal", "qt_signal_emit"}]
+
+
+def assert_no_wrong_signal(result, payload, graph, site):
+ """Unsupported aliases may remain unresolved; wrong persisted edges may not."""
+ wrong = native_signal(result, "Sender")
+ forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]}
+ links = endpoint_links(payload, site)
+ assert not [edge for edge in links if edge["target"] in forbidden], links
+ for edge in links:
+ assert graph.has_edge(edge["source"], edge["target"])
+ metadata = qt_metadata(site)
+ assert metadata.get("signal_target_id") not in forbidden, metadata
+ assert metadata.get("target_id") not in forbidden, metadata
+
+
+@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"])
+def test_req_qml016_connect_alias_cannot_select_unrelated_global_signal(tmp_path, alias):
+ """A local alias shadows Sender; connect must not persist global Sender's signal."""
+ source = DECLARATIONS + f"""void wire(Other *original, Receiver *receiver) {{
+ {alias}
+ Sender *sender = original;
+ QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);
+}}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ site = native_facts(result, "connect")[0]
+ assert_no_wrong_signal(result, payload, graph, site)
+
+
+@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"])
+def test_req_qml016_emission_alias_cannot_select_unrelated_global_signal(tmp_path, alias):
+ """An explicit alias-typed receiver supplies no authority for the global class."""
+ source = DECLARATIONS + f"""void send(Other *original) {{
+ {alias}
+ Sender *sender = original;
+ emit sender->changed(1);
+}}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ assert_no_wrong_signal(result, payload, graph, native_facts(result, "emission")[0])
+
+
+def registration_proof(root, alias, raw_type="Sender"):
+ source = DECLARATIONS + f"""void install() {{
+ {alias}
+ qmlRegisterType<{raw_type}>("Demo", 1, 0, "Thing");
+}}
+"""
+ result, payload, graph = persisted(root, source, qml=True)
+ bridge = build_qt_qml_bridge(result["nodes"], result["edges"], root=root)
+ provider = bridge.module_type("Demo", 1, 0, "Thing")
+ handlers = [node for node in payload["nodes"] if qml_metadata(node).get("kind") == "handler"]
+ assert len(handlers) == 1
+ return result, payload, graph, bridge, provider, handlers[0]
+
+
+@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"])
+def test_req_qml008_alias_registration_cannot_export_global_class_to_qml(tmp_path, alias):
+ """Completed native registration→QML handler edges must honor type authority."""
+ result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, alias)
+ wrong = native_signal(result, "Sender")
+ forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]}
+ links = [edge for edge in payload["links"] if edge["source"] == handler["id"]
+ and edge.get("context") == "qml_signal_subscription"]
+ assert not [edge for edge in links if edge["target"] in forbidden], links
+ assert qml_metadata(handler).get("resolved_target_id") not in forbidden
+ wrong_class = qt_metadata(wrong)["class_id"]
+ assert qt_metadata(native_facts(result, "registration")[0]).get("class_id") != wrong_class
+ if provider.target_id:
+ assert bridge.provider_records[provider.target_id]["class_id"] != wrong_class
+ for edge in links:
+ assert graph.has_edge(edge["source"], edge["target"])
+
+
+def test_req_qml016_block_alias_does_not_change_before_and_after_native_scope(tmp_path):
+ """The alias applies inside its block; globally suppressing Sender is insufficient."""
+ source = DECLARATIONS + """void wire(Sender *outer, Other *other, Receiver *receiver) {
+ QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept);
+ { using Sender = Other; Sender *inner = other;
+ QObject::connect(inner, &Sender::changed, receiver, &Receiver::accept); }
+ QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept);
+}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ before, inside, after = native_facts(result, "connect")
+ target = native_signal(result, "Sender")["id"]
+ assert qt_metadata(before)["signal_target_id"] == target
+ assert qt_metadata(after)["signal_target_id"] == target
+ assert {edge["target"] for edge in endpoint_links(payload, before)} == {target}
+ assert {edge["target"] for edge in endpoint_links(payload, after)} == {target}
+ assert_no_wrong_signal(result, payload, graph, inside)
+
+
+@pytest.mark.parametrize("class_name", ["Sender", "Other"])
+def test_direct_native_type_control_preserves_roles_and_direction(tmp_path, class_name):
+ """Unshadowed connections/emissions retain distinct roles and logical direction."""
+ source = DECLARATIONS + f"""void wire({class_name} *sender, Receiver *receiver) {{
+ QObject::connect(sender, &{class_name}::changed, receiver, &Receiver::accept);
+ emit sender->changed(1);
+}}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ connection = native_facts(result, "connect")[0]
+ emission = native_facts(result, "emission")[0]
+ signal = native_signal(result, class_name)["id"]
+ assert qt_metadata(connection)["status"] == qt_metadata(emission)["status"] == "resolved"
+ for site in (connection, emission):
+ links = endpoint_links(payload, site)
+ assert len(links) == 1 and links[0]["target"] == signal
+ assert graph.has_edge(links[0]["source"], signal)
+ assert not graph.has_edge(signal, links[0]["source"])
+ roles = [node for node in payload["nodes"]
+ if qt_metadata(node).get("kind") == "event_endpoint"
+ and qt_metadata(node).get("owner_id") == connection["id"]]
+ assert {qt_metadata(node)["role"] for node in roles} == {"signal", "receiver"}
+ assert len({node["id"] for node in roles}) == 2
+ assert not any(edge["source"] == connection["id"] and edge.get("relation") == "calls"
+ for edge in payload["links"])
+
+
+@pytest.mark.parametrize("class_name", ["Sender", "Other"])
+def test_direct_registration_control_preserves_native_qml_endpoint(tmp_path, class_name):
+ """A literal unshadowed registration exports only its actual native signal."""
+ result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, "", class_name)
+ signal = native_signal(result, class_name)
+ assert provider.status == "resolved"
+ assert bridge.provider_records[provider.target_id]["class_id"] == qt_metadata(signal)["class_id"]
+ target = qt_metadata(signal)["generic_target_id"]
+ assert qml_metadata(handler)["status"] == "resolved"
+ assert qml_metadata(handler)["resolved_target_id"] == target
+ links = [edge for edge in payload["links"] if edge["source"] == handler["id"]
+ and edge.get("context") == "qml_signal_subscription"]
+ assert len(links) == 1 and links[0]["target"] == target
+ assert graph.has_edge(handler["id"], target) and not graph.has_edge(target, handler["id"])
diff --git a/tests/audit/probe_qt_qml_object_boundaries.py b/tests/audit/probe_qt_qml_object_boundaries.py
new file mode 100644
index 0000000000..107797087d
--- /dev/null
+++ b/tests/audit/probe_qt_qml_object_boundaries.py
@@ -0,0 +1,139 @@
+"""Opt-in public audit of receiver identity; expected rejection assertions stay strict.
+
+Invoke this probe explicitly with pytest. Its name excludes normal ``test_*.py``
+discovery. Synthetic sources pass through production extraction, graph assembly
+and JSON reload; neither QML nor a Qt application/build hook executes.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+
+BACKEND = '''class Backend : public QObject {
+ Q_OBJECT
+ Q_PROPERTY(int count READ count)
+public:
+ int count() { return 1; }
+};
+'''
+QML = '''import QtQuick
+Item {
+ property int rootOnly: 7
+ function rootAction() {}
+ Item { objectName: "left"; property int localValue: 1; Item { objectName: "deep" } }
+ Item { objectName: "right"; property int localValue: 2 }
+}
+'''
+
+
+def persisted(root, sources):
+ """Read only synthetic inputs and inspect the actual completed JSON output."""
+ result = analysis(root, sources)
+ graph = build_from_json(result, root=root)
+ output = root / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ return result, load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+
+
+def access_source(root, tail):
+ """The literal file URL establishes a receiver without runtime execution."""
+ return f'''void use() {{
+ QQuickView view;
+ view.setSource(QUrl("{(root / 'Main.qml').as_uri()}"));
+ auto root = view.rootObject();
+ auto left = root->findChild("left");
+ {tail}
+}}'''
+
+
+def target_edges(graph, site, contexts):
+ """Select semantic target links by their serialized logical source direction."""
+ return [(data.get("_tgt", target), data.get("relation"), data.get("context"))
+ for source, target, data in graph.edges(site["id"], data=True)
+ if data.get("_src", source) == site["id"] and data.get("context") in contexts]
+
+
+@pytest.mark.parametrize("tail", ['left->property("rootOnly");', 'left->setProperty("rootOnly", 9);',
+ 'QMetaObject::invokeMethod(left, "rootAction");'])
+def test_cpp_reflective_child_property_does_not_inherit_qml_lexical_root(tmp_path, tail):
+ """REQ-QML-017-AC02/AC04: a child must not acquire its QML root's reflected API."""
+ result, graph = persisted(tmp_path, {"access.cpp": access_source(tmp_path, tail), "Main.qml": QML})
+ access = sites(result, "qml_access")[-1]
+ span = qt_metadata(access)["span"]
+ assert (tmp_path / "access.cpp").read_bytes()[span["start_byte"]:span["end_byte"]].decode() == tail[:-1]
+ assert not target_edges(graph, access, {"qt_cpp_qml_property_read", "qt_cpp_qml_property_write", "qt_cpp_qml_invoke"}), (
+ "The child's read/write/invoke site persisted an enclosing-root member edge")
+ assert qt_metadata(access)["status"] != "resolved"
+
+
+def test_cpp_findchild_does_not_search_sibling_outside_receiver_subtree(tmp_path):
+ """REQ-QML-017-AC02/AC04: left has no right child; a sibling is not its target."""
+ result, graph = persisted(tmp_path, {"access.cpp": access_source(tmp_path, 'left->findChild("right");'),
+ "Main.qml": QML})
+ access = sites(result, "qml_access")[-1]
+ assert not target_edges(graph, access, {"qt_cpp_qml_find_child"}), "A sibling target edge survived build/JSON reload"
+ assert qt_metadata(access)["status"] != "resolved"
+
+
+def test_cpp_findchild_direct_search_cannot_select_grandchild(tmp_path):
+ """REQ-QML-017-AC02/AC04: direct-child search must not choose nested deep."""
+ result, graph = persisted(tmp_path, {"access.cpp": access_source(tmp_path,
+ 'root->findChild("deep", Qt::FindDirectChildrenOnly);'),
+ "Main.qml": QML})
+ access = sites(result, "qml_access")[-1]
+ assert not target_edges(graph, access, {"qt_cpp_qml_find_child"}), "Direct-child search persisted a grandchild edge"
+ assert qt_metadata(access)["status"] != "resolved"
+
+
+def test_disjoint_engine_scopes_cannot_supply_context_provider(tmp_path):
+ """REQ-QML-017-AC03/AC04: a destroyed engine cannot inject into a new engine."""
+ source = BACKEND + f'''void use(Backend *backend) {{
+ {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("backend", backend);
+ }}
+ QQmlApplicationEngine engine;
+ engine.load(QUrl("{(tmp_path / 'Main.qml').as_uri()}"));
+}}'''
+ result, graph = persisted(tmp_path, {"access.cpp": source,
+ "Main.qml": 'import QtQml\nQtObject { property int value: backend.count }'})
+ joins = sites(result, "context_access")
+ assert not [(node["id"], edge) for node in joins for edge in target_edges(graph, node, {"qt_context_member"})], (
+ "A block-local engine's provider edge reached another engine's loaded component")
+ assert not joins
+
+
+def test_public_receiver_and_engine_positive_controls(tmp_path):
+ """Valid own-receiver lookups and same-engine context joins remain resolvable."""
+ source = access_source(tmp_path, 'left->property("localValue"); root->property("rootOnly"); root->findChild("right");')
+ result, graph = persisted(tmp_path, {"access.cpp": source, "Main.qml": QML})
+ accesses = sites(result, "qml_access")
+ assert len(accesses) == 4 and all(qt_metadata(node)["status"] == "resolved" for node in accesses)
+ assert all(graph.has_edge(node["id"], qt_metadata(node)["target_id"]) for node in accesses)
+ folder = tmp_path / "context"
+ folder.mkdir()
+ source = BACKEND + f'''void use(Backend *backend) {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("backend", backend);
+ engine.load(QUrl("{(folder / 'Main.qml').as_uri()}")); }}'''
+ result, graph = persisted(folder, {"access.cpp": source,
+ "Main.qml": 'import QtQml\nQtObject { property int value: backend.count }'})
+ joins = sites(result, "context_access")
+ assert len(joins) == 1 and graph.has_edge(joins[0]["id"], qt_metadata(joins[0])["target_id"])
+
+
+def test_pending_typed_factory_provider_remains_unresolved(tmp_path):
+ """REQ-QML-018-AC03 remains a planned gap; unavailable factory types are not guessed."""
+ source = BACKEND + f'''class Factory {{ public: Backend *makeBackend(); }};
+void use(Factory *factory) {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("backend", factory->makeBackend());
+ engine.load(QUrl("{(tmp_path / 'Main.qml').as_uri()}")); }}'''
+ result, _ = persisted(tmp_path, {"access.cpp": source,
+ "Main.qml": 'import QtQml\nQtObject { property int value: backend.count }'})
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+ assert qt_metadata(sites(result, "context_exposure")[0])["status"] == "unavailable"
diff --git a/tests/audit/probe_qt_reflection_type_shadowing.py b/tests/audit/probe_qt_reflection_type_shadowing.py
new file mode 100644
index 0000000000..cb0bb4da77
--- /dev/null
+++ b/tests/audit/probe_qt_reflection_type_shadowing.py
@@ -0,0 +1,112 @@
+"""Opt-in INC-QML-22 reflection identity audit; rejection assertions stay strict.
+
+Explicit pytest invocation is required: ``probe_*.py`` is outside normal discovery.
+Public synthetic inputs reach extraction, build, JSON publication and reload. No
+Qt compiler, application, QML component or corpus build hook executes. The source
+class controls describe accepted parser inputs, not claims of successful Qt builds.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qml_resolution_types import qml_metadata
+from tests.qt_analysis_helpers import analysis, sites
+
+QML = 'import QtQml\nQtObject { property int count: 1; function refresh() {} }'
+OPERATIONS = {
+ "invoke": ('static void invokeMethod(QObject *, const char *) {}',
+ 'QMetaObject::invokeMethod(root, "refresh");', "QMetaObject"),
+ "static_read": ('static QVariant read(QObject *, const char *) {}',
+ 'QQmlProperty::read(root, "count");', "QQmlProperty"),
+ "static_write": ('static bool write(QObject *, const char *, QVariant) {}',
+ 'QQmlProperty::write(root, "count", 2);', "QQmlProperty"),
+ "property_handle": ('OtherProperty(QObject *, const char *) {} QVariant read() {}',
+ 'QQmlProperty handle(root, "count"); handle.read();', "QQmlProperty"),
+}
+TARGET_CONTEXTS = {"qt_cpp_qml_invoke", "qt_cpp_qml_property_read", "qt_cpp_qml_property_write"}
+
+
+def source(root, operation, shadow):
+ """A real source loader supplies the receiver; the reflection type varies."""
+ member, tail, sdk_name = OPERATIONS[operation]
+ definition, alias, namespace = "", "", ""
+ if shadow == "local_alias":
+ other = "OtherMeta" if sdk_name == "QMetaObject" else "OtherProperty"
+ definition = f"struct {other} {{ {member} }};"
+ alias = f"using {sdk_name} = {other};"
+ elif shadow in {"global_source_class", "namespace_source_class"}:
+ definition = f"struct {sdk_name} {{ {member.replace('OtherProperty(', 'QQmlProperty(')} }};"
+ namespace = "Public" if shadow == "namespace_source_class" else ""
+ elif shadow == "sdk_namespace_control":
+ namespace = "Public"
+ elif shadow != "sdk_control":
+ raise ValueError(shadow)
+ body = f'''// Original-byte evidence: café
+{definition}
+void use() {{ QQuickView view;
+ view.setSource(QUrl("{(root / 'Main.qml').as_uri()}"));
+ auto root = view.rootObject();
+ {alias}
+ {tail}
+}}'''
+ return (("namespace " + namespace + " {\n" + body + "\n}") if namespace else body).replace("\n", "\r\n")
+
+
+def persisted(root, operation, shadow):
+ """Check actual durable direction and original source evidence before rejection."""
+ original = source(root, operation, shadow)
+ result = analysis(root, {"access.cpp": original, "Main.qml": QML})
+ graph = build_from_json(result, root=root)
+ output = root / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ restored = load_node_link_graph(payload)
+ accesses = sites(result, "qml_access")
+ by_id = {node["id"]: node for node in accesses}
+ for node in accesses:
+ metadata = qt_metadata(node)
+ span = metadata["span"]
+ text = original.encode()[span["start_byte"]:span["end_byte"]].decode()
+ assert text and text in OPERATIONS[operation][1]
+ assert node["source_file"] == "access.cpp"
+ assert node["source_location"].startswith("L")
+ links = [edge for edge in payload["links"] if edge["source"] in by_id
+ and edge.get("context") in TARGET_CONTEXTS]
+ for edge in links:
+ site = by_id[edge["source"]]
+ assert restored.has_edge(edge["source"], edge["target"])
+ actual = restored.edges[edge["source"], edge["target"]]
+ assert (actual["_src"], actual["_tgt"]) == (edge["source"], edge["target"])
+ assert actual["relation"] == ("calls" if operation == "invoke" else "uses")
+ assert actual["confidence"] == "INFERRED"
+ assert actual["source_file"] == "access.cpp"
+ assert actual["source_location"] == site["source_location"]
+ assert qt_metadata(actual)["span"] == qt_metadata(site)["span"]
+ return result, accesses, links
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+@pytest.mark.parametrize("shadow", ["local_alias", "global_source_class", "namespace_source_class"])
+def test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target(tmp_path, operation, shadow):
+ """A local alias or source class spelling is not the Qt reflection SDK class."""
+ _, _, links = persisted(tmp_path, operation, shadow)
+ assert not links, "Unrelated reflection class persisted a false QML member target"
+
+
+@pytest.mark.parametrize("operation, shadow", [
+ ("invoke", "sdk_namespace_control"), ("property_handle", "sdk_control"),
+])
+def test_req_qml017_ac04_unshadowed_sdk_reflection_preserves_targets_and_provenance(tmp_path, operation, shadow):
+ """The correction must preserve genuine SDK calls, including namespace scope."""
+ result, accesses, links = persisted(tmp_path, operation, shadow)
+ assert len(links) == (1 if operation == "invoke" else 2)
+ assert accesses and all(qt_metadata(node)["status"] == "resolved" for node in accesses)
+ nodes = {node["id"]: node for node in result["nodes"]}
+ assert {qml_metadata(nodes[edge["target"]])["raw_name"] for edge in links} == {
+ "refresh" if operation == "invoke" else "count"}
diff --git a/tests/fixtures/qml/parser_probe/Empty.qml b/tests/fixtures/qml/parser_probe/Empty.qml
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/fixtures/qml/parser_probe/Grouped.qml b/tests/fixtures/qml/parser_probe/Grouped.qml
new file mode 100644
index 0000000000..0d404474ee
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/Grouped.qml
@@ -0,0 +1,8 @@
+// Lowercase grouped properties must not become instantiated component types.
+import QtQuick
+Text {
+ id: label
+ anchors { left: parent.left; topMargin: 4 }
+ font { pixelSize: 12; bold: true }
+ text: "Grouped properties"
+}
diff --git a/tests/fixtures/qml/parser_probe/Imports.qml b/tests/fixtures/qml/parser_probe/Imports.qml
new file mode 100644
index 0000000000..23e672f3ec
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/Imports.qml
@@ -0,0 +1,6 @@
+// Major-only, full-version, versionless and local import forms stay distinct.
+import QtQuick 6
+import Example.Widgets 1.2
+import Example.Services as Services
+import "./components"
+Item {}
diff --git a/tests/fixtures/qml/parser_probe/Incomplete.qml b/tests/fixtures/qml/parser_probe/Incomplete.qml
new file mode 100644
index 0000000000..69ca0997da
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/Incomplete.qml
@@ -0,0 +1,7 @@
+// Deliberately unfinished editor buffer; preserve earlier declaration ranges.
+import QtQuick
+Item {
+ id: root
+ property int retained: 7
+ function unfinished() {
+ return retained +
diff --git a/tests/fixtures/qml/parser_probe/Main.qml b/tests/fixtures/qml/parser_probe/Main.qml
new file mode 100644
index 0000000000..0ff60d0411
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/Main.qml
@@ -0,0 +1,34 @@
+// Synthetic Qt 6.5 syntax profile; parsing does not validate runtime types.
+import QtQuick
+import QtQuick.Controls 6.5 as Controls
+import "./components" as Local
+import "helpers.js" as Helpers
+
+Item {
+ id: root
+ required property string title
+ readonly property int doubled: count * 2
+ default property list extras
+ property int count: 1
+ property alias editorText: editor.text
+ signal activated(int value, string label)
+ signal changed(value: int)
+
+ function bump(step: int): int {
+ const total = count + step;
+ count = total;
+ activated(total, title);
+ return total;
+ }
+
+ onCountChanged: {
+ const message = "punctuation: {}; //";
+ if (count > 0) { bump(1); }
+ }
+
+ Controls.TextField {
+ id: editor
+ text: root.title
+ onTextChanged: root.count = text.length
+ }
+}
diff --git a/tests/fixtures/qml/parser_probe/Malformed.qml b/tests/fixtures/qml/parser_probe/Malformed.qml
new file mode 100644
index 0000000000..f8e3d0edc7
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/Malformed.qml
@@ -0,0 +1,6 @@
+// Invalid tokens are deliberate; the later property should remain recoverable.
+import QtQuick
+Item {
+ property int broken: @@@;
+ property string retained: "safe"
+}
diff --git a/tests/fixtures/qml/parser_probe/Modern68.qml b/tests/fixtures/qml/parser_probe/Modern68.qml
new file mode 100644
index 0000000000..9507aaf416
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/Modern68.qml
@@ -0,0 +1,16 @@
+// Synthetic Qt 6.8 syntax profile; this is not a running Qt application.
+pragma ComponentBehavior: Bound
+import QtQuick 6.8
+
+Item {
+ id: outer
+ enum Mode { Idle = 0, Active = 1 }
+ component Tile: Rectangle {
+ id: tile
+ required property int value
+ property alias ownValue: tile.value
+ }
+ Tile { value: 3 }
+ property var mapper: (value) => value + 1
+ Component.onCompleted: mapper(Mode.Active)
+}
diff --git a/tests/fixtures/qml/parser_probe/Script.qml b/tests/fixtures/qml/parser_probe/Script.qml
new file mode 100644
index 0000000000..73c318f132
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/Script.qml
@@ -0,0 +1,16 @@
+// Comments and strings deliberately imitate QML; the parser must keep JS scopes.
+import QtQuick
+Item {
+ property var transform: (value) => ({ label: "}", value: value + 1 })
+ function evaluate(input) {
+ let value = input;
+ { let value = 3; value += 1; }
+ const outside = value;
+ return transform(outside).value;
+ }
+ Component.onCompleted: {
+ const literal = "signal fake(int x) { import Broken }";
+ /* A brace } and fake declaration: signal phantom() */
+ evaluate(2);
+ }
+}
diff --git a/tests/fixtures/qml/parser_probe/UnicodeCRLF.qml b/tests/fixtures/qml/parser_probe/UnicodeCRLF.qml
new file mode 100644
index 0000000000..be5aecc082
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/UnicodeCRLF.qml
@@ -0,0 +1,7 @@
+// UTF-8 and CRLF are intentional; columns count bytes, not Unicode characters.
+import QtQuick
+Item {
+ id: root
+ property string café: "雪😀"; property int après: 2
+ function résumé(étape) { return café + étape; }
+}
diff --git a/tests/fixtures/qml/parser_probe/plugins.qmltypes b/tests/fixtures/qml/parser_probe/plugins.qmltypes
new file mode 100644
index 0000000000..e38b96c2c3
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/plugins.qmltypes
@@ -0,0 +1,12 @@
+// Synthetic type-description syntax, not metadata produced by a private project.
+import QtQuick.tooling 1.2
+Module {
+ Component {
+ name: "Example::Backend"
+ prototype: "QObject"
+ exports: ["Example/Backend 1.0"]
+ Property { name: "value"; type: "int"; isReadonly: true }
+ Method { name: "reset" }
+ Signal { name: "updated"; Parameter { name: "value"; type: "int" } }
+ }
+}
diff --git a/tests/fixtures/qml/parser_probe/qmldir b/tests/fixtures/qml/parser_probe/qmldir
new file mode 100644
index 0000000000..cc856a22c8
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/qmldir
@@ -0,0 +1,9 @@
+# Synthetic basic module descriptor.
+module Example.Widgets
+Main 1.0 Main.qml
+singleton Theme 1.0 Theme.qml
+internal Helper Helper.qml
+typeinfo plugins.qmltypes
+plugin exampleplugin
+classname ExamplePlugin
+depends QtQuick.Controls 6.5
diff --git a/tests/fixtures/qml/parser_probe/qmldir.modern b/tests/fixtures/qml/parser_probe/qmldir.modern
new file mode 100644
index 0000000000..f50e6ecdb5
--- /dev/null
+++ b/tests/fixtures/qml/parser_probe/qmldir.modern
@@ -0,0 +1,6 @@
+# Qt 6 metadata forms require their own syntax coverage decision.
+module Example.Widgets
+optional plugin exampleplugin
+import QtQuick auto
+prefer :/qt/qml/Example/Widgets/
+Main Main.qml
diff --git a/tests/fixtures/qml/project_metadata/CMakeLists.txt b/tests/fixtures/qml/project_metadata/CMakeLists.txt
new file mode 100644
index 0000000000..d218fa6538
--- /dev/null
+++ b/tests/fixtures/qml/project_metadata/CMakeLists.txt
@@ -0,0 +1,12 @@
+# Literal Qt 6 module fixture; analysis must never configure this project.
+cmake_minimum_required(VERSION 3.21)
+project(PublicTools LANGUAGES CXX)
+find_package(Qt6 REQUIRED COMPONENTS Qml Quick)
+qt_add_qml_module(tools
+ URI Public.Tools
+ VERSION 1.0
+ RESOURCE_PREFIX /ui
+ QML_FILES Main.qml
+ SOURCES backend.h
+ IMPORTS QtQuick/2.15
+)
diff --git a/tests/fixtures/qml/project_metadata/Main.qml b/tests/fixtures/qml/project_metadata/Main.qml
new file mode 100644
index 0000000000..dff94408f2
--- /dev/null
+++ b/tests/fixtures/qml/project_metadata/Main.qml
@@ -0,0 +1,10 @@
+import QtQuick 2.15
+import Public.Tools 1.0
+
+Item {
+ id: root
+ objectName: "publicRoot"
+ property int status: service.status
+ Service { id: service }
+ function refresh() { service.refresh() }
+}
diff --git a/tests/fixtures/qml/project_metadata/backend.h b/tests/fixtures/qml/project_metadata/backend.h
new file mode 100644
index 0000000000..353fa80972
--- /dev/null
+++ b/tests/fixtures/qml/project_metadata/backend.h
@@ -0,0 +1,14 @@
+#pragma once
+#include
+#include
+
+class Backend : public QObject {
+ Q_OBJECT
+ QML_NAMED_ELEMENT(Service)
+ Q_PROPERTY(int status READ status NOTIFY statusChanged)
+public:
+ int status() const { return 1; }
+ Q_INVOKABLE void refresh() { emit statusChanged(); }
+signals:
+ void statusChanged();
+};
diff --git a/tests/fixtures/qml/project_metadata/resources.qrc b/tests/fixtures/qml/project_metadata/resources.qrc
new file mode 100644
index 0000000000..b5e947a092
--- /dev/null
+++ b/tests/fixtures/qml/project_metadata/resources.qrc
@@ -0,0 +1,5 @@
+
+
+ Main.qml
+
+
diff --git a/tests/fixtures/qml/project_metadata/tools.pro b/tests/fixtures/qml/project_metadata/tools.pro
new file mode 100644
index 0000000000..616c468e1f
--- /dev/null
+++ b/tests/fixtures/qml/project_metadata/tools.pro
@@ -0,0 +1,9 @@
+# Equivalent literal Qt 6 qmake module context.
+TEMPLATE = lib
+TARGET = tools
+QT += qml quick
+CONFIG += qmltypes
+QML_IMPORT_NAME = Public.Tools
+QML_IMPORT_VERSION = 1.0
+HEADERS += backend.h
+RESOURCES += resources.qrc
diff --git a/tests/qml_ci.py b/tests/qml_ci.py
new file mode 100644
index 0000000000..a85ed0add3
--- /dev/null
+++ b/tests/qml_ci.py
@@ -0,0 +1,48 @@
+"""Verify optional QML wheels in clean environments on each CI host/Python lane."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+
+def run(*args, **kwargs):
+ subprocess.run(list(args), check=True, **kwargs)
+
+
+def interpreter(environment):
+ return str(environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))
+
+
+def main():
+ root = Path(__file__).resolve().parent.parent
+ workspace = root / ".venv/qml-ci"
+ wheel_directory = workspace / "wheel"
+ run("uv", "build", "--wheel", "--python", sys.executable, "--out-dir", str(wheel_directory), cwd=root)
+ wheels = list(wheel_directory.glob("*.whl"))
+ assert len(wheels) == 1, "Expected exactly one reviewed Graphify wheel"
+ wheel = wheels[0]
+ extra, core = workspace / "extra", workspace / "core"
+ for environment in (extra, core):
+ run("uv", "venv", "--python", sys.executable, str(environment))
+ run("uv", "pip", "install", "--python", interpreter(extra), str(wheel) + "[qml,watch]",
+ "pytest>=8,<10", "packaging")
+ run("uv", "pip", "install", "--python", interpreter(core), str(wheel))
+ smoke = str(root / "tests/qml_installed_smoke.py")
+ with tempfile.TemporaryDirectory() as neutral:
+ run(interpreter(extra), "-I", smoke, cwd=neutral)
+ run(interpreter(core), "-I", smoke, "--core-only", cwd=neutral)
+ environment = dict(os.environ, GRAPHIFY_QML_TEST_WHEEL=str(wheel), PYTHONUTF8="1")
+ # Joint upstream/Qt boundaries belong to the same native source profile;
+ # Linux's all-extras job owns them while this lane keeps isolated smokes.
+ tests = (["tests/test_qml_wheel_artifact.py"] if "--artifact-only" in sys.argv
+ else sorted(str(path.relative_to(root)) for path in (root / "tests").glob("test_*.py")
+ if path.name.startswith(("test_qml_", "test_qt_", "test_upstream_qt_"))))
+ run(interpreter(extra), "-X", "utf8", "-m", "pytest", *tests, "-q", "-ra", "--tb=short",
+ cwd=root, env=environment)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/qml_expression_helpers.py b/tests/qml_expression_helpers.py
new file mode 100644
index 0000000000..82afca3cc3
--- /dev/null
+++ b/tests/qml_expression_helpers.py
@@ -0,0 +1,33 @@
+"""Production extractor fixtures for scoped QML/JS expression acceptance."""
+
+from graphify.extract import extract
+from graphify.extractors.qml_facts import qml_metadata
+
+
+def extraction(root, sources):
+ paths = []
+ for name, source in sources.items():
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(source, encoding="utf-8")
+ paths.append(path)
+ result = extract(paths, root=root, cache_root=root, parallel=False)
+ assert not result.get("qml_failures"), result.get("qml_failures")
+ return result
+
+
+def nodes(result, kind, name=None, file=None):
+ return [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind
+ and (name is None or qml_metadata(node).get("raw_name") == name)
+ and (file is None or node["source_file"] == file)]
+
+
+def single(result, kind, name=None, file=None):
+ found = nodes(result, kind, name, file)
+ assert len(found) == 1, (kind, name, file, found)
+ return found[0]
+
+
+def target_edges(result, site, context=None):
+ return [edge for edge in result["edges"] if edge["source"] == site["id"]
+ and edge["relation"] != "contains" and (context is None or edge.get("context") == context)]
diff --git a/tests/qml_installed_smoke.py b/tests/qml_installed_smoke.py
new file mode 100644
index 0000000000..29e76468c0
--- /dev/null
+++ b/tests/qml_installed_smoke.py
@@ -0,0 +1,127 @@
+"""Run with an isolated interpreter to verify a clean installed Graphify wheel.
+
+This intentionally uses no pytest/source-checkout imports. CI supplies either
+the qml extra or --core-only, then checks an actual production entry point.
+"""
+from __future__ import annotations
+
+import importlib.metadata
+import json
+from pathlib import Path
+import sys
+import tempfile
+
+
+def offline_guard(event, _args):
+ if event in {"socket.connect", "socket.getaddrinfo", "subprocess.Popen", "os.system"}:
+ raise RuntimeError("Analysis attempted a network/process operation")
+
+
+def native_module_smoke(root):
+ """Installed facade joins accepted build membership and literal resources."""
+ from graphify.build import build_from_json
+ from graphify.extract import extract
+ from graphify.extractors.qml_facts import qml_metadata
+ from graphify.extractors.qt_cpp_facts import qt_metadata
+
+ root.mkdir()
+ files = {
+ "backend.h": 'class Backend : public QObject { Q_OBJECT QML_ELEMENT '
+ 'Q_PROPERTY(int count READ count) public: int count() { return 1; } '
+ 'Q_INVOKABLE int next(int value) { return value; } };',
+ "Main.qml": 'import Installed.Tools 1.0\nBackend { property int copied: count; '
+ 'function run() { next(1) } }',
+ "loader.cpp": 'void load(){ QQmlApplicationEngine engine; '
+ 'engine.load(QUrl("qrc:/ui/Main.qml")); }',
+ "CMakeLists.txt": 'qt_add_qml_module(installed URI Installed.Tools VERSION 1.0 '
+ 'QML_FILES Main.qml SOURCES backend.h loader.cpp)',
+ "resources.qrc": ''
+ 'Main.qml',
+ }
+ paths = []
+ for name, source in files.items():
+ path = root / name
+ path.write_text(source, encoding="utf-8")
+ paths.append(path)
+ result = extract(paths, root=root, cache_root=root, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ graph = build_from_json(result, root=root, directed=True)
+ call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call")
+ proof = qt_metadata(call)["native_endpoint"]
+ assert proof["canonical_target_id"] == call["target"]
+ assert graph.nodes[call["target"]]["source_file"] == "backend.h"
+ assert graph.nodes[call["target"]]["label"].endswith(".next()")
+ assert graph.has_edge(call["source"], call["target"])
+ assert qt_metadata(graph.nodes[proof["provider_id"]])["kind"] == "registration"
+ modules = [graph.nodes[item] for item in proof["evidence"]
+ if qml_metadata(graph.nodes[item]).get("kind") == "qt_module"]
+ assert len(modules) == 1 and modules[0]["source_file"] == "CMakeLists.txt"
+ assert qml_metadata(modules[0])["uri"] == "Installed.Tools"
+ loader = next(node for node in result["nodes"] if qt_metadata(node).get("kind") == "qml_load")
+ component = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "component")
+ assert qt_metadata(loader)["status"] == "resolved"
+ assert qt_metadata(loader)["target_id"] == component["id"]
+ edge = graph.edges[loader["id"], component["id"]]
+ assert edge["context"] == "qt_cpp_qml_load" and edge["source_file"] == "loader.cpp"
+
+
+def main():
+ sys.addaudithook(offline_guard)
+ from graphify.extract import extract, extract_python
+ from graphify.extractors.qml import extract_qml
+ from graphify.extractors.qml_metadata import extract_qmldir
+ from graphify.extractors.qml_facts import qml_metadata
+ from graphify.build import build_from_json
+ from graphify.detect import FileType, classify_file
+ from graphify.validate import validate_extraction
+
+ assert "site-packages" in (sys.modules["graphify"].__file__ or "")
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ path = root / "Main.qml"
+ path.write_text('import "helpers.js" as Tools\nItem { id: root; property int value: 3; property int derived: value + Tools.twice(value); signal ready(int eventValue); onReady: { value = Tools.twice(eventValue) } function twice() { return value * 2; } }', encoding="utf-8")
+ assert classify_file(path) == FileType.CODE
+ metadata = root / "qmldir"
+ original = b'\xef\xbb\xbfmodule Sample.Tools\r\nThing 1.0 Thing.qml\r\n'
+ metadata.write_bytes(original)
+ declarations = extract_qmldir(metadata, root=root)
+ module = next(node for node in declarations["nodes"] if qml_metadata(node)["kind"] == "module")
+ assert qml_metadata(module)["span"]["start_byte"] == 3
+ assert qml_metadata(module)["span"]["end_byte"] == original.index(b'\r\n')
+ from graphify.extractors.qt_cpp_facts import qt_metadata
+ native = root / "Backend.cpp"
+ native.write_text("class Backend : public QObject { Q_OBJECT public: void tick() { emit ready(1); } signals: void ready(int value); };", encoding="utf-8")
+ cpp = extract([native], root=root, cache_root=root, parallel=False)
+ assert not cpp["qml_failures"] and not cpp["failed_sources"]
+ emissions = [node for node in cpp["nodes"] if qt_metadata(node).get("kind") == "emission"]
+ assert len(emissions) == 1 and qt_metadata(emissions[0])["status"] == "resolved"
+ assert any(edge.get("context") == "qt_signal_emit" for edge in cpp["edges"])
+ result = extract_qml(path, root=root)
+ if "--core-only" in sys.argv:
+ assert result["diagnostics"][0]["code"] == "QML_PARSER_MISSING"
+ assert result["nodes"] == []
+ python = root / "main.py"
+ python.write_text("def safe():\n return 1\n", encoding="utf-8")
+ assert any(n["label"] == "safe()" for n in extract_python(python, root=root)["nodes"])
+ else:
+ script = root / "helpers.js"
+ script.write_text('.pragma library\nfunction twice(value) { return value * 2; }', encoding="utf-8")
+ assert not result.get("error")
+ declarations = {(n["metadata"]["qml"]["kind"], n["metadata"]["qml"]["raw_name"])
+ for n in result["nodes"] if "raw_name" in n["metadata"]["qml"]}
+ assert {("component", "Main"), ("property", "value"), ("function", "twice")} <= declarations
+ batch = extract([path, script], root=root, cache_root=root, parallel=False)
+ assert not batch["failed_sources"] and not batch["qml_failures"]
+ assert validate_extraction(batch) == []
+ graph = build_from_json(batch, directed=True, root=root)
+ assert graph.number_of_nodes() == len(batch["nodes"])
+ assert {"qml_binding_read", "qml_script_call", "qml_signal_subscription"} <= {
+ edge.get("context") for edge in batch["edges"]}
+ native_module_smoke(root / "native-profile")
+ print(json.dumps({"python": sys.version.split()[0], "graphify": importlib.metadata.version("graphifyy"),
+ "tree_sitter": importlib.metadata.version("tree-sitter"),
+ "mode": "core-only" if "--core-only" in sys.argv else "qml", "status": "passed"}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/qml_persistence_helpers.py b/tests/qml_persistence_helpers.py
new file mode 100644
index 0000000000..fddc588e81
--- /dev/null
+++ b/tests/qml_persistence_helpers.py
@@ -0,0 +1,61 @@
+"""Offline parser boundary fakes and real persistence snapshots for QML gates."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extractors.base import _file_stem, _make_id
+
+
+def healthy_qml(path: Path) -> dict:
+ """A complete parser contribution with a containment edge to preserve."""
+ file_id = _make_id(str(path))
+ component_id = _make_id(_file_stem(path), "component")
+ common = {"source_file": str(path), "file_type": "code", "source_location": "L1"}
+ return {
+ "nodes": [
+ {**common, "id": file_id, "label": path.name},
+ {**common, "id": component_id, "label": "Main"},
+ ],
+ "edges": [{"source": file_id, "target": component_id, "relation": "contains",
+ "confidence": "EXTRACTED", "source_file": str(path), "source_location": "L1"}],
+ }
+
+
+def failing_qml(kind: str, *, extra_nodes: int = 0):
+ """Inject parser failure, including surviving nodes that hide an edge loss."""
+ errors = {
+ "missing": "QML_PARSER_MISSING: tree-sitter-language-pack not installed",
+ "incompatible": "QML_PARSER_LOAD: QML grammar failed to load",
+ "read": "QML_SOURCE_READ: source could not be read",
+ "partial": "QML_PARSE_PARTIAL: syntax recovery lost a declaration",
+ }
+
+ def extract(path):
+ result = healthy_qml(path)
+ result["edges"] = []
+ for i in range(extra_nodes):
+ result["nodes"].append({
+ "id": f"replacement_{i}", "label": f"replacement{i}", "file_type": "code",
+ "source_file": str(path), "source_location": "L1",
+ })
+ result["error"] = errors[kind]
+ return result
+
+ return extract
+
+
+def make_project(tmp_path: Path) -> Path:
+ project = tmp_path / "project"
+ project.mkdir()
+ (project / "Main.qml").write_text("import QtQuick\nItem { property int count: 1 }\n", encoding="utf-8")
+ (project / "keep.py").write_text("def retained(): return 1\n", encoding="utf-8")
+ return project
+
+
+def protected_snapshot(out: Path) -> dict[str, bytes]:
+ """Measure completed durable products, excluding retryable cache/lock files."""
+ return {
+ p.name: p.read_bytes() for p in out.iterdir()
+ if p.name in {"graph.json", "manifest.json", "GRAPH_REPORT.md", "graph.html",
+ ".graphify_root", ".graphify_labels.json", "callflow.html"}
+ }
diff --git a/tests/qml_probe_helpers.py b/tests/qml_probe_helpers.py
new file mode 100644
index 0000000000..c5c7ce2d48
--- /dev/null
+++ b/tests/qml_probe_helpers.py
@@ -0,0 +1,87 @@
+"""Nonshipping QML-00 AST inspection and fresh-process offline probe helpers."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import overload
+
+from tree_sitter import Node
+
+
+def walk(root: Node) -> Iterator[Node]:
+ """Visit every concrete node, including errors and missing syntax tokens."""
+ pending = [root]
+ while pending:
+ node = pending.pop()
+ yield node
+ pending.extend(reversed(node.children))
+
+
+def nodes(root: Node, kind: str) -> list[Node]:
+ return [node for node in walk(root) if node.type == kind]
+
+
+@overload
+def text(node: Node) -> str: ...
+
+
+@overload
+def text(node: None) -> None: ...
+
+
+def text(node: Node | None) -> str | None:
+ if node is None:
+ return None
+ content = node.text
+ assert content is not None, "Probe nodes retain their original parsed source"
+ return content.decode("utf-8")
+
+
+def field_text(node: Node, field: str) -> str | None:
+ return text(node.child_by_field_name(field))
+
+
+def point_at(source: bytes, offset: int) -> tuple[int, int]:
+ """Calculate rows and byte columns independently from Tree-sitter nodes."""
+ prefix = source[:offset]
+ return prefix.count(b"\n"), offset - (prefix.rfind(b"\n") + 1)
+
+
+# The child starts with isolated Python, an empty working/cache directory, and an
+# audit hook installed before parser imports. This proves Python-network-free
+# installed invocation, rather than claiming an OS-wide network sandbox.
+OFFLINE_PROBE = r'''
+import json
+from pathlib import Path
+import socket
+import sys
+
+def deny_external_execution(event, args):
+ if event in {"socket.connect", "socket.getaddrinfo", "subprocess.Popen", "os.system"}:
+ raise RuntimeError("QML-00 external execution denied")
+
+sys.addaudithook(deny_external_execution)
+try:
+ socket.create_connection(("127.0.0.1", 9))
+except RuntimeError:
+ network_denied = True
+else:
+ raise AssertionError("network guard did not reject the control attempt")
+
+from tree_sitter import Language, Parser
+from tree_sitter_language_pack import get_binding, get_parser
+
+fixtures = Path(sys.argv[1])
+results = {}
+for name, filename in (("qmljs", "Main.qml"), ("qmldir", "qmldir")):
+ parser = Parser(Language(get_binding(name)))
+ source = (fixtures / filename).read_bytes()
+ root = parser.parse(source).root_node
+ results[name] = {"has_error": root.has_error, "end_byte": root.end_byte, "size": len(source)}
+try:
+ get_parser("qml_probe_missing_grammar")
+except LookupError:
+ unavailable = "LookupError"
+else:
+ raise AssertionError("missing grammar unexpectedly succeeded")
+print(json.dumps({"network_denied": network_denied, "unavailable": unavailable, "results": results}))
+'''
diff --git a/tests/qml_resolution_helpers.py b/tests/qml_resolution_helpers.py
new file mode 100644
index 0000000000..432146c6c5
--- /dev/null
+++ b/tests/qml_resolution_helpers.py
@@ -0,0 +1,48 @@
+"""Hand-authored public module fixtures through the actual source extractors."""
+
+from graphify.extractors.qml import extract_qml
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_metadata import extract_qmldir
+from graphify.qml_resolution import build_qml_index
+
+
+def corpus(root, sources):
+ """Only supplied sources enter the index; its resolver cannot widen discovery."""
+ per_file = {}
+ for relative, text in sources.items():
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ if path.suffix in {".js", ".mjs"}:
+ from graphify.extract import extract_js
+ result = extract_js(path)
+ else:
+ result = (extract_qmldir(path, root=root) if path.name == "qmldir" else
+ extract_qml(path, root=root))
+ assert not result.get("error"), result
+ per_file[path] = result
+ nodes = [node for result in per_file.values() for node in result["nodes"]]
+ edges = [edge for result in per_file.values() for edge in result["edges"]]
+ return per_file, nodes, edges
+
+
+def named(nodes, file, kind, name=None):
+ selected = [node for node in nodes if node["source_file"] == file and
+ qml_metadata(node).get("kind") == kind and
+ (name is None or qml_metadata(node).get("raw_name") == name)]
+ assert len(selected) == 1, (file, kind, name, selected)
+ return selected[0]
+
+
+def index_for(root, sources, *, import_roots=None):
+ per_file, nodes, edges = corpus(root, sources)
+ return build_qml_index(nodes, edges, root=root, import_roots=import_roots), per_file, nodes, edges
+
+
+def component_key(nodes, file="Main.qml", name=None):
+ return qml_metadata(named(nodes, file, "component", name)).get("component_key")
+
+
+def module(uri, filename="Button.qml", *, extra=""):
+ return {f'{uri.replace(".", "/")}/qmldir': f"module {uri}\nButton 1.0 {filename}\n{extra}",
+ f'{uri.replace(".", "/")}/{filename}': "QtObject { property int value: 1 }"}
diff --git a/tests/qml_test_helpers.py b/tests/qml_test_helpers.py
new file mode 100644
index 0000000000..9e1d0e4a3e
--- /dev/null
+++ b/tests/qml_test_helpers.py
@@ -0,0 +1,130 @@
+"""Public hand-checked QML inputs and structural assertions for production tests."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import re
+from typing import Any
+
+
+# Independent declarations-only contract: JS bodies and expressions remain opaque
+# in QML-01, while owner scopes and imported spellings remain inspectable.
+DECLARATIONS = '''import QtQuick
+import QtQuick.Controls 6.5 as Controls
+import "widgets" as Local
+import "helpers.js" as Helpers
+Item {
+ id: root
+ property int count: 1
+ required property string title
+ readonly property int doubled: count * 2
+ default property list extras
+ property alias editorText: editor.text
+ signal activated(int value, string label)
+ function bump(step: int): int {
+ count += step;
+ activated(count, title);
+ return count;
+ }
+ Controls.TextField {
+ id: editor
+ property string caption: "kept"
+ text: root.title
+ }
+}
+'''
+
+DECLARATION_KINDS = {"file", "component", "inline_component", "object", "property_group", "property", "signal", "function", "import"}
+
+
+def write_qml(root: Path, relative: str = "Main.qml", source: str = DECLARATIONS) -> Path:
+ """Write only synthetic input, retaining original UTF-8 and line endings."""
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(source.encode("utf-8"))
+ return path
+
+
+def qml(node: dict[str, Any]) -> dict[str, Any]:
+ return node["metadata"]["qml"]
+
+
+def by_kind(result: dict[str, Any], kind: str) -> list[dict[str, Any]]:
+ return [node for node in result["nodes"] if qml(node)["kind"] == kind]
+
+
+def one(result: dict[str, Any], kind: str, name: str | None = None) -> dict[str, Any]:
+ selected = by_kind(result, kind)
+ if name is not None:
+ selected = [node for node in selected if qml(node)["raw_name"] == name]
+ assert len(selected) == 1, (kind, name, len(selected))
+ return selected[0]
+
+
+def canonical(result: dict[str, Any]) -> str:
+ """Compare facts without assuming parallel/cache emission list ordering."""
+ facts = {key: sorted(result.get(key, []), key=lambda item: json.dumps(item, sort_keys=True)) for key in ("nodes", "edges", "raw_calls", "diagnostics")}
+ return json.dumps(facts, ensure_ascii=False, sort_keys=True)
+
+
+def assert_contains(result: dict[str, Any], parent: dict[str, Any], child: dict[str, Any]) -> None:
+ assert any(edge["source"] == parent["id"] and edge["target"] == child["id"] and edge["relation"] == "contains" for edge in result["edges"])
+
+
+def assert_spans(result: dict[str, Any], source: bytes) -> None:
+ """Independently recompute zero-based byte/point positions from original input."""
+ for node in result["nodes"]:
+ span = qml(node)["span"]
+ assert 0 <= span["start_byte"] <= span["end_byte"] <= len(source)
+ for side in ("start", "end"):
+ offset = span[f"{side}_byte"]
+ prefix = source[:offset]
+ assert span[f"{side}_row"] == prefix.count(b"\n")
+ assert span[f"{side}_column"] == offset - prefix.rfind(b"\n") - 1
+ assert node["source_location"].startswith(f'L{span["start_row"] + 1}')
+
+
+def assert_no_machine_paths(value: Any, root: Path) -> None:
+ """Check nested values even when exception repr/JSON doubled Windows separators."""
+ if isinstance(value, dict):
+ for child in value.values():
+ assert_no_machine_paths(child, root)
+ elif isinstance(value, (list, tuple)):
+ for child in value:
+ assert_no_machine_paths(child, root)
+ elif isinstance(value, str):
+ assert root.as_posix() not in re.sub(r"[\\/]+", "/", value)
+
+
+# Probe the built package in a fresh interpreter, never importing its source
+# checkout. Core absence is forced before imports at the optional-library boundary.
+WHEEL_PROBE = r'''
+import importlib.abc
+import json
+from pathlib import Path
+import sys
+
+target, source_root, mode = map(Path, sys.argv[1:])
+sys.path.insert(0, str(target))
+
+class MissingOptionalGrammar(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == "tree_sitter_language_pack" or fullname.startswith("tree_sitter_language_pack."):
+ raise ModuleNotFoundError("QML optional parser unavailable in core-only probe")
+
+if str(mode) == "core":
+ sys.meta_path.insert(0, MissingOptionalGrammar())
+ for name in list(sys.modules):
+ if name.startswith("tree_sitter_language_pack"):
+ del sys.modules[name]
+
+import graphify
+from graphify.extractors.qml import extract_qml
+from graphify.extract import extract_python
+
+assert Path(graphify.__file__).resolve().is_relative_to(target.resolve())
+qml_result = extract_qml(source_root / "Main.qml", root=source_root)
+python_result = extract_python(source_root / "safe.py", root=source_root)
+assert any(node["label"] == "safe()" for node in python_result["nodes"])
+print(json.dumps({"qml": qml_result, "python_ok": True}))
+'''
diff --git a/tests/qt_adoption_fixture.py b/tests/qt_adoption_fixture.py
new file mode 100644
index 0000000000..1c0dfb003f
--- /dev/null
+++ b/tests/qt_adoption_fixture.py
@@ -0,0 +1,44 @@
+"""Public hand-checked declared API types; no analyzed project code executes."""
+from __future__ import annotations
+
+NATIVE = '''class Service : public QObject { Q_OBJECT
+ Q_PROPERTY(int count READ count)
+public: int count() { return 1; }
+ Q_INVOKABLE void refresh() {}
+signals: void ready();
+};
+class Backend : public QObject { Q_OBJECT
+ Q_PROPERTY(Service* service READ service)
+public: Service *service() { throw 7; }
+};
+class Factory {
+public: Backend *makeBackend() { throw 7; }
+ Backend *backend;
+};
+'''
+
+QML = '''import QtQml
+QtObject {
+ property int count: backend.service.count
+ function handleReady() {}
+ function run() {
+ backend.service.refresh()
+ backend.service.ready.connect(handleReady)
+ }
+ property Connections subscription: Connections {
+ target: backend.service
+ function onReady() { handleReady() }
+ }
+}
+'''
+
+
+def sources(root, expression="factory->makeBackend()", *, qml=QML, native=NATIVE):
+ """Literal loading establishes component scope; factory bodies are irrelevant."""
+ return {
+ "native.h": native,
+ "access.cpp": '#include "native.h"\nvoid use(Factory *factory, Backend *backend) {'
+ ' QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("backend", '
+ + expression + '); engine.load(QUrl("' + (root / "Main.qml").as_uri() + '")); }\n',
+ "Main.qml": qml,
+ }
diff --git a/tests/qt_analysis_helpers.py b/tests/qt_analysis_helpers.py
new file mode 100644
index 0000000000..5720f0d739
--- /dev/null
+++ b/tests/qt_analysis_helpers.py
@@ -0,0 +1,20 @@
+"""Accepted production extraction boundary for Qt source overlay regressions."""
+from graphify.extract import extract
+from graphify.extractors.qt_cpp_facts import qt_metadata
+
+
+def analysis(root, sources):
+ paths = []
+ for name, source in sources.items():
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(source, encoding="utf-8", newline="")
+ paths.append(path)
+ result = extract(paths, root=root, cache_root=root, parallel=False)
+ assert not result.get("qml_failures"), result.get("qml_failures")
+ return result
+
+
+def sites(result, kind, operation=None):
+ return [node for node in result["nodes"] if qt_metadata(node).get("kind") == kind
+ and (operation is None or qt_metadata(node).get("operation") == operation)]
diff --git a/tests/qt_combined_adoption_fixture.py b/tests/qt_combined_adoption_fixture.py
new file mode 100644
index 0000000000..306e1e4a5c
--- /dev/null
+++ b/tests/qt_combined_adoption_fixture.py
@@ -0,0 +1,40 @@
+"""Public Qt 6 adoption corpus: metadata, typed providers and inherited overload sites."""
+from __future__ import annotations
+
+from tests.qt_adoption_fixture import NATIVE, QML, sources
+
+
+CMAKE = "qt_add_qml_module(app URI Public.Adoption VERSION 1.0 QML_FILES Main.qml SOURCES native.h access.cpp)\n"
+QMAKE = """QT += qml quick
+CONFIG += c++17
+QML_IMPORT_NAME = Public.Adoption
+QML_IMPORT_VERSION = 1.0
+HEADERS += $$PWD/native.h
+SOURCES += $$PWD/access.cpp
+QML_FILES += $$PWD/Main.qml
+QML_IMPORT_PATH += $$PWD/imports
+QMLPATHS += $$PWD/imports
+"""
+
+
+def corpus(root, build_system):
+ """Bodies throw if executed; source analysis consumes declared API types only."""
+ app = root / "app"
+ native = NATIVE.replace("class Backend : public QObject", "class\nBackend : public Middle")
+ native = "class Grand : public QObject { Q_OBJECT signals: void changed(); };\n" + \
+ "class Middle : public Grand { Q_OBJECT };\n" + native
+ native = native.replace("public: Service *service()", "public: Backend(); Backend(int value); Service *service()")
+ data = sources(app, native=native)
+ data["access.cpp"] += "Backend::Backend() : Backend(0) { emit changed(); }\nBackend::Backend(int value) {}\n"
+ data["CMakeLists.txt" if build_system == "cmake" else "application.pro"] = CMAKE if build_system == "cmake" else QMAKE
+ # Explicit analysis configuration grants this module visibility. A qmake
+ # tooling/build hint alone cannot enlarge the accepted corpus or lookup roots.
+ data["imports/Public/Extra/qmldir"] = "module Public.Extra\nExtra 1.0 Extra.qml\n"
+ data["imports/Public/Extra/Extra.qml"] = "import QtQml\nQtObject { property int value: 3 }\n"
+ data["Consumer.qml"] = "import Public.Extra 1.0\nExtra {}\n"
+ for name, source in data.items():
+ path = app / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(source.encode("utf-8"))
+ (root / "keep.py").write_text("def retained(): return 7\n", encoding="utf-8")
+ return app
diff --git a/tests/qt_consumer_helpers.py b/tests/qt_consumer_helpers.py
new file mode 100644
index 0000000000..c0e77b99c3
--- /dev/null
+++ b/tests/qt_consumer_helpers.py
@@ -0,0 +1,31 @@
+"""Saved production graph fixtures shared across CLI/HTML/MCP acceptance."""
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from tests.qt_analysis_helpers import analysis
+
+
+def saved_graph(root):
+ result = analysis(root, {"Main.qml": '''import QtQml
+QtObject { id: root; property int input: 1; property int output: input;
+ signal changed(); onChanged: { output = input }
+ function run() { changed() }
+}''', "Other.qml": 'import QtQml\nQtObject { property int input: 2 }'})
+ graph = build_from_json(result, root=root, directed=True)
+ output = root / "graph.json"
+ assert to_json(graph, {}, str(output), force=True)
+ component = next(node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "component")
+ prop = next(node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "property" and qml_metadata(node).get("raw_name") == "input")
+ read = next(node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "read" and qml_metadata(node).get("reference") == "input")
+ return output, result, component, prop, read
+
+
+def cli(monkeypatch, capsys, graph, *arguments):
+ import graphify.__main__ as main
+ # Query stamps belong to the isolated persisted fixture, not the checkout.
+ monkeypatch.chdir(graph.parent)
+ monkeypatch.setattr(main, "_check_skill_version", lambda *_: None)
+ monkeypatch.setattr(main.sys, "argv", ["graphify", *arguments, "--graph", str(graph)])
+ capsys.readouterr()
+ main.main()
+ return capsys.readouterr().out.strip()
diff --git a/tests/qt_cpp_test_helpers.py b/tests/qt_cpp_test_helpers.py
new file mode 100644
index 0000000000..09aa726290
--- /dev/null
+++ b/tests/qt_cpp_test_helpers.py
@@ -0,0 +1,54 @@
+"""Public synthetic Qt inputs using Graphify's actual canonical C++ extractor."""
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+
+from graphify.extract import extract
+from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp
+from graphify.extractors.qt_cpp_facts import qt_metadata
+
+HEADER = '''class Backend : public QObject {
+ Q_OBJECT
+ QML_NAMED_ELEMENT(Backend)
+ Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)
+public:
+ int value() const;
+ Q_INVOKABLE int next(int amount);
+ void ordinary(int value);
+signals:
+ void valueChanged(int value);
+public slots:
+ void setValue(int value);
+private:
+ Q_INVOKABLE void hidden();
+};
+'''
+
+
+def write(root, name, source):
+ path = Path(root) / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(source.encode())
+ return path
+
+
+def collect(root, files):
+ paths = [write(root, name, source) for name, source in files.items()]
+ accepted = extract(paths, root=Path(root), cache_root=Path(root) / ".probe-cache", parallel=False)
+ # A production integration may already have run overlays. This independent
+ # collector boundary borrows only existing generic declarations unchanged.
+ nodes = [node for node in accepted["nodes"] if not qt_metadata(node)]
+ ids = {node["id"] for node in nodes}
+ edges = [edge for edge in accepted["edges"] if edge["source"] in ids and edge["target"] in ids]
+ snapshot = copy.deepcopy((nodes, edges))
+ results = [{"nodes": [], "edges": []} for _ in paths]
+ enrich_qt_cpp(paths, results, root=root, accepted_nodes=nodes, accepted_edges=edges)
+ assert (nodes, edges) == snapshot
+ return {"nodes": nodes + [node for result in results for node in result["nodes"]],
+ "edges": edges + [edge for result in results for edge in result["edges"]], "per_file": results}
+
+
+def facts(result, kind, name=None):
+ return [node for node in result["nodes"] if (md := qt_metadata(node)).get("kind") == kind
+ and (name is None or md.get("raw_name") == name)]
diff --git a/tests/shell_portability.py b/tests/shell_portability.py
new file mode 100644
index 0000000000..cc4ecde59e
--- /dev/null
+++ b/tests/shell_portability.py
@@ -0,0 +1,56 @@
+"""Admit explicit shell executables and preserve native/MSYS file identity.
+
+Shells may report a POSIX spelling for a file created by native Windows Python.
+Convert only through the reporting shell's own path mapper; drive-prefix guesses
+cannot handle MSYS mounts such as ``/tmp`` and can hide a wrong candidate.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+
+def select_shell_executable(shell: str = "sh") -> str:
+ """Select Bash/sh only from configured PATH, then launch its absolute identity."""
+ if shell not in {"bash", "sh"}:
+ raise AssertionError("only Bash/sh are admitted by shell fixtures")
+ configured = os.environ.get("PATH", "")
+ if not configured:
+ raise AssertionError(f"{shell} is unavailable in configured PATH")
+ # A qualified candidate avoids Windows which/CreateProcess adding CWD or
+ # system directories before PATH. Explicit empty/dot entries still admit CWD.
+ for directory in configured.split(os.pathsep):
+ candidate = Path(directory or os.curdir).absolute() / shell
+ executable = shutil.which(str(candidate))
+ if executable is None:
+ continue
+ identity = Path(executable)
+ if not identity.is_absolute() or not identity.is_file():
+ raise AssertionError(f"{shell} selection is not an absolute executable file")
+ return str(identity.resolve(strict=True))
+ raise AssertionError(f"{shell} is unavailable in configured PATH")
+
+
+def resolve_shell_path(value: str, *, shell: str = "sh") -> Path:
+ """Resolve one existing absolute path in the reporting shell's namespace."""
+ if os.name == "nt" and value.startswith("/"):
+ executable = select_shell_executable(shell)
+ system = subprocess.check_output(
+ [executable, "-c", "uname -s"], text=True,
+ ).strip()
+ if not system.startswith(("MSYS_", "MINGW", "CYGWIN_")):
+ raise AssertionError(f"unsupported Windows shell path namespace: {system}")
+ # Use this shell installation's mapper, not another cygpath on PATH.
+ mapper = Path(executable).with_name("cygpath.exe")
+ mapped = subprocess.check_output(
+ [str(mapper), "-w", "--", value], text=True,
+ ).splitlines()
+ if len(mapped) != 1 or not mapped[0]:
+ raise AssertionError("shell path conversion did not return one path")
+ value = mapped[0]
+ path = Path(value)
+ if not path.is_absolute():
+ raise AssertionError(f"shell reported a relative file identity: {value!r}")
+ return path.resolve(strict=True)
diff --git a/tests/test_atomic_replace_retention.py b/tests/test_atomic_replace_retention.py
new file mode 100644
index 0000000000..4271ea139f
--- /dev/null
+++ b/tests/test_atomic_replace_retention.py
@@ -0,0 +1,220 @@
+"""REQ-QML-018-AC06: replacement failure never masquerades as committed data."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import shutil
+import stat
+
+import pytest
+
+from graphify.paths import os_replace_with_fallback, write_json_atomic, write_text_atomic
+
+
+def fallback(monkeypatch):
+ """Only the OS replace boundary is forced; real copy/rename/unlink still run."""
+ def unavailable(*_):
+ raise PermissionError("replace temporarily unavailable")
+ monkeypatch.setattr(os, "replace", unavailable)
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute")
+@pytest.mark.parametrize("writer", ["text", "json", "replace"])
+def test_req_qml018_ac06_readonly_rejects_without_displacing_destination(tmp_path, writer):
+ """The actual OS rejection keeps bytes/permissions; repair then succeeds."""
+ target, source = tmp_path / "graph.json", tmp_path / "source.tmp"
+ target.write_bytes(b'{"original":true}')
+ original = target.read_bytes()
+ source.write_bytes(b'{"new":true}')
+ target.chmod(stat.S_IREAD)
+ try:
+ with pytest.raises(PermissionError):
+ if writer == "text":
+ write_text_atomic(target, '{"new":true}')
+ elif writer == "json":
+ write_json_atomic(target, {"new": True})
+ else:
+ os_replace_with_fallback(source, target)
+ assert target.read_bytes() == original
+ assert not target.stat().st_mode & stat.S_IWRITE
+ assert source.read_bytes() == b'{"new":true}'
+ assert {p.name for p in tmp_path.iterdir()} == {"graph.json", "source.tmp"}
+ finally:
+ target.chmod(stat.S_IWRITE)
+ write_text_atomic(target, '{"new":true}')
+ accepted = target.read_bytes()
+ write_text_atomic(target, '{"new":true}')
+ assert target.read_bytes() == accepted
+
+
+def test_req_qml018_ac06_directory_is_not_a_replace_fallback_target(tmp_path, monkeypatch):
+ """A destination directory must not be renamed away to land a plain file."""
+ source, target = tmp_path / "source.tmp", tmp_path / "directory"
+ source.write_bytes(b"NEW")
+ target.mkdir()
+ (target / "keep").write_bytes(b"KEEP")
+ fallback(monkeypatch)
+ with pytest.raises(PermissionError):
+ os_replace_with_fallback(source, target)
+ assert (target / "keep").read_bytes() == b"KEEP"
+ assert source.read_bytes() == b"NEW"
+ assert {p.name for p in tmp_path.iterdir()} == {"source.tmp", "directory"}
+
+
+@pytest.mark.parametrize("phase,existing", [
+ ("copy", True), ("backup", True), ("land", True),
+ ("source_cleanup", True), ("backup_cleanup", True),
+ ("copy", False), ("land", False), ("source_cleanup", False),
+])
+def test_req_qml018_ac06_fallback_failure_restores_prior_state(tmp_path, monkeypatch, phase, existing):
+ """Each actual fallback phase can fail without publishing a false success."""
+ source, target = tmp_path / "source.tmp", tmp_path / "graph.json"
+ source.write_bytes(b"NEW")
+ if existing:
+ target.write_bytes(b"ORIGINAL")
+ fallback(monkeypatch)
+ real_copy, real_rename, real_unlink = shutil.copy2, os.rename, os.unlink
+ raised = False
+
+ def reject():
+ nonlocal raised
+ raised = True
+ raise OSError("injected " + phase)
+
+ def copy(start, destination, *args, **kwargs):
+ if phase == "copy" and not raised:
+ reject()
+ return real_copy(start, destination, *args, **kwargs)
+
+ def rename(start, destination, *args, **kwargs):
+ if not raised and ((phase == "backup" and Path(start) == target)
+ or (phase == "land" and Path(start).name.startswith(".gfy-replace-")
+ and Path(destination) == target)):
+ reject()
+ return real_rename(start, destination, *args, **kwargs)
+
+ def unlink(path, *args, **kwargs):
+ candidate = Path(path)
+ if not raised and ((phase == "source_cleanup" and candidate == source)
+ or (phase == "backup_cleanup" and candidate.name.startswith(".gfy-replace-bak-")
+ and candidate.read_bytes() == b"ORIGINAL")):
+ reject()
+ return real_unlink(path, *args, **kwargs)
+
+ monkeypatch.setattr(shutil, "copy2", copy)
+ monkeypatch.setattr(os, "rename", rename)
+ monkeypatch.setattr(os, "unlink", unlink)
+ with pytest.raises(OSError, match="injected " + phase):
+ os_replace_with_fallback(source, target)
+ assert raised and source.read_bytes() == b"NEW"
+ assert target.read_bytes() == b"ORIGINAL" if existing else not target.exists()
+ assert {p.name for p in tmp_path.iterdir()} == ({"source.tmp", "graph.json"} if existing else {"source.tmp"})
+ os_replace_with_fallback(source, target)
+ assert target.read_bytes() == b"NEW" and not source.exists()
+ assert {p.name for p in tmp_path.iterdir()} == {"graph.json"}
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute")
+def test_req_qml018_ac06_readonly_source_is_consumed_on_successful_fallback(tmp_path, monkeypatch):
+ """The move contract consumes an authorized source, including its read-only bit."""
+ source, target = tmp_path / "source.tmp", tmp_path / "graph.json"
+ source.write_bytes(b"NEW")
+ target.write_bytes(b"ORIGINAL")
+ source.chmod(stat.S_IREAD)
+ fallback(monkeypatch)
+ try:
+ os_replace_with_fallback(source, target)
+ assert target.read_bytes() == b"NEW" and not source.exists()
+ assert not target.stat().st_mode & stat.S_IWRITE
+ assert {p.name for p in tmp_path.iterdir()} == {"graph.json"}
+ finally:
+ for path in (source, target):
+ if path.exists():
+ path.chmod(stat.S_IWRITE)
+
+
+def test_req_qml018_ac06_restore_failure_keeps_recovery_backup(tmp_path, monkeypatch):
+ """A second OS failure must expose integrity failure and retain original bytes."""
+ source, target = tmp_path / "source.tmp", tmp_path / "graph.json"
+ source.write_bytes(b"NEW")
+ target.write_bytes(b"ORIGINAL")
+ fallback(monkeypatch)
+ real_rename = os.rename
+
+ def fail_landing_and_restore(start, destination):
+ if Path(destination) == target:
+ raise OSError("landing/restore unavailable")
+ return real_rename(start, destination)
+
+ monkeypatch.setattr(os, "rename", fail_landing_and_restore)
+ with pytest.raises(OSError, match="rollback failed"):
+ os_replace_with_fallback(source, target)
+ backups = list(tmp_path.glob(".gfy-replace-bak-*.tmp"))
+ assert len(backups) == 1 and backups[0].read_bytes() == b"ORIGINAL"
+ assert source.read_bytes() == b"NEW" and not target.exists()
+ assert list(tmp_path.glob(".gfy-replace-*.tmp")) == backups
+ monkeypatch.setattr(os, "rename", real_rename)
+ real_rename(backups[0], target)
+ os_replace_with_fallback(source, target)
+ assert target.read_bytes() == b"NEW"
+ assert {p.name for p in tmp_path.iterdir()} == {"graph.json"}
+
+
+def test_req_qml018_ac06_absent_destination_restore_failure_has_no_fake_backup(tmp_path, monkeypatch):
+ """An unavoidable second OS failure reports recovery without inventing a backup."""
+ source, target = tmp_path / "source.tmp", tmp_path / "graph.json"
+ source.write_bytes(b"NEW")
+ fallback(monkeypatch)
+ real_rename, real_unlink = os.rename, os.unlink
+
+ def unlink(path, *args, **kwargs):
+ if Path(path) == source:
+ raise OSError("source cleanup unavailable")
+ return real_unlink(path, *args, **kwargs)
+
+ def rename(start, destination):
+ if Path(start) == target:
+ raise OSError("candidate rollback unavailable")
+ return real_rename(start, destination)
+
+ monkeypatch.setattr(os, "unlink", unlink)
+ monkeypatch.setattr(os, "rename", rename)
+ with pytest.raises(OSError, match="rollback failed; manual recovery required"):
+ os_replace_with_fallback(source, target)
+ assert source.read_bytes() == target.read_bytes() == b"NEW"
+ assert not list(tmp_path.glob(".gfy-*.tmp"))
+ monkeypatch.setattr(os, "unlink", real_unlink)
+ monkeypatch.setattr(os, "rename", real_rename)
+ target.unlink()
+ os_replace_with_fallback(source, target)
+ assert target.read_bytes() == b"NEW" and not source.exists()
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute")
+def test_req_qml018_ac06_readonly_source_cleanup_rollback_retains_attributes(tmp_path, monkeypatch):
+ """A consumed source is reconstructed before restoring a failed cleanup transaction."""
+ source, target = tmp_path / "source.tmp", tmp_path / "graph.json"
+ source.write_bytes(b"NEW")
+ source.chmod(stat.S_IREAD)
+ target.write_bytes(b"ORIGINAL")
+ fallback(monkeypatch)
+ real_unlink = os.unlink
+ rejected = False
+
+ def unlink(path, *args, **kwargs):
+ nonlocal rejected
+ if not rejected and Path(path).name.startswith(".gfy-replace-bak-") and Path(path).read_bytes() == b"ORIGINAL":
+ rejected = True
+ raise OSError("backup cleanup unavailable")
+ return real_unlink(path, *args, **kwargs)
+
+ monkeypatch.setattr(os, "unlink", unlink)
+ try:
+ with pytest.raises(OSError, match="backup cleanup unavailable"):
+ os_replace_with_fallback(source, target)
+ assert target.read_bytes() == b"ORIGINAL" and target.stat().st_mode & stat.S_IWRITE
+ assert source.read_bytes() == b"NEW" and not source.stat().st_mode & stat.S_IWRITE
+ assert {p.name for p in tmp_path.iterdir()} == {"source.tmp", "graph.json"}
+ finally:
+ if source.exists():
+ source.chmod(stat.S_IWRITE)
diff --git a/tests/test_ci_windows_tools.py b/tests/test_ci_windows_tools.py
new file mode 100644
index 0000000000..4d90e8bba8
--- /dev/null
+++ b/tests/test_ci_windows_tools.py
@@ -0,0 +1,109 @@
+"""REQ-CORE-004: execute hosted Windows tool selection through real PowerShell."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+
+import pytest
+
+
+pytestmark = pytest.mark.skipif(os.name != "nt", reason="Native Windows PowerShell application discovery")
+WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml"
+
+
+def assignment(tool):
+ """The workflow owns the executable selector; tests execute its actual RHS."""
+ matches = re.findall(rf"^\s*\$proof{tool.capitalize()}\s*=\s*(.+)$",
+ WORKFLOW.read_text(encoding="utf-8"), re.MULTILINE)
+ assert len(matches) == 1, f"Expected one workflow-owned {tool} selector"
+ return matches[0]
+
+
+def node_version_guard():
+ lines = [line.strip() for line in WORKFLOW.read_text(encoding="utf-8").splitlines()
+ if line.strip().startswith("if ((& $proofNode --version)")]
+ assert len(lines) == 1, "Expected one workflow-owned Node admission guard"
+ return lines[0]
+
+
+@pytest.fixture
+def powershell():
+ """Use the real native shell even when validation excludes installers from PATH."""
+ shell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
+ assert shell.is_file(), "Required native Windows PowerShell is unavailable"
+ return shell
+
+
+def real_binary(tool):
+ """uv is an explicit proof dependency, retained off the corpus consumer PATH."""
+ selected = os.environ.get("GRAPHIFY_TEST_UV") if tool == "uv" else None
+ selected = selected or shutil.which(tool)
+ if not selected:
+ pytest.skip(f"Real {tool} executable unavailable; hosted proof must admit its test dependency")
+ path = Path(selected).resolve()
+ assert path.is_file(), f"Selected {tool} executable is missing"
+ return path
+
+
+def execute(shell, body, directories):
+ """Restrict only the child process PATH; no installer or machine setting changes."""
+ environment = dict(os.environ)
+ system = Path(os.environ["SystemRoot"]) / "System32"
+ environment["PATH"] = os.pathsep.join(str(path) for path in (*directories, system))
+ script = "$ErrorActionPreference = 'Stop'\n"
+ script += "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)\n" + body
+ return subprocess.run([str(shell), "-NoProfile", "-NonInteractive", "-Command", script],
+ env=environment, capture_output=True, text=True, encoding="utf-8", timeout=30)
+
+
+@pytest.mark.parametrize("tool", ["node", "uv"])
+def test_req_core004_ac02_workflow_executes_first_real_path_application(
+ tmp_path, powershell, tool):
+ """Two actual binaries reproduce hosted discovery; one scalar first path executes."""
+ original = real_binary(tool)
+ preferred = tmp_path / "preferred application directory" / (tool + ".exe")
+ preferred.parent.mkdir()
+ shutil.copy2(original, preferred)
+ variable = "$proof" + tool.capitalize()
+ body = variable + " = " + assignment(tool) + "\n"
+ body += "$version = (& " + variable + " --version)\n"
+ body += "if ($LASTEXITCODE -ne 0) { throw 'Selected real application cannot execute' }\n"
+ body += "$candidates = @((Get-Command " + tool + " -CommandType Application).Source)\n"
+ body += "[ordered]@{selected=" + variable + ";version=$version;candidates=$candidates} | ConvertTo-Json -Compress\n"
+ result = execute(powershell, body, (preferred.parent, original.parent))
+ assert result.returncode == 0, result.stderr
+ evidence = json.loads(result.stdout)
+ assert isinstance(evidence["selected"], str) and Path(evidence["selected"]) == preferred
+ assert [Path(path) for path in evidence["candidates"]] == [preferred, original]
+ assert evidence["version"], "Actual selected executable must complete its version command"
+ if tool == "node":
+ identity = execute(powershell, variable + " = " + assignment(tool)
+ + "\n& " + variable + " -p process.execPath", (preferred.parent, original.parent))
+ assert identity.returncode == 0 and Path(identity.stdout.strip()) == preferred
+
+
+@pytest.mark.parametrize("tool", ["node", "uv"])
+def test_req_core004_ac03_missing_workflow_application_has_no_fallback(powershell, tool):
+ """The actual selector rejects absent PATH authority before invoking any application."""
+ result = execute(powershell, "$proof" + tool.capitalize() + " = " + assignment(tool), ())
+ assert result.returncode != 0
+ assert "CommandNotFoundException" in result.stderr and f"'{tool}'" in result.stderr
+
+
+def test_req_core004_ac03_node_guard_rejects_real_incompatible_application(
+ tmp_path, powershell):
+ """A real uv binary named node cannot bypass the workflow's exact Node version gate."""
+ original = real_binary("uv")
+ incompatible = tmp_path / "incompatible application directory" / "node.exe"
+ incompatible.parent.mkdir()
+ shutil.copy2(original, incompatible)
+ body = "$proofNode = " + assignment("node") + "\n"
+ body += "& $proofNode --version\n" + node_version_guard()
+ result = execute(powershell, body, (incompatible.parent,))
+ assert result.returncode != 0
+ assert result.stdout.startswith("uv "), "The real incompatible executable must run"
+ assert "The reviewed Node.js test interpreter was not selected" in result.stderr
diff --git a/tests/test_cpp_constructor_ownership.py b/tests/test_cpp_constructor_ownership.py
new file mode 100644
index 0000000000..3701625c2e
--- /dev/null
+++ b/tests/test_cpp_constructor_ownership.py
@@ -0,0 +1,220 @@
+"""REQ-QML-008-AC02: generic constructor facts retain proven class ownership."""
+import copy
+import base64
+
+import pytest
+
+from graphify.extract import extract, extract_cpp
+from graphify.extractors.base import _file_stem, _make_id
+from graphify.extractors.cpp_constructors import bind_cpp_constructors, constructor_merge_allowed
+
+
+def corpus(root, header, implementation):
+ """Use the real generic producer and aggregate with explicit corpus ownership."""
+ paths = [root / "backend.hpp", root / "backend.cpp"]
+ for path, source in zip(paths, (header, '#include "backend.hpp"\n' + implementation)):
+ path.write_text(source, encoding="utf-8", newline="")
+ return paths
+
+
+def owner_edges(result, constructor):
+ return [edge for edge in result["edges"] if edge["target"] == constructor["id"]
+ and edge["relation"] == "method" and edge["confidence"] == "EXTRACTED"]
+
+
+@pytest.mark.parametrize("prototype,definition", [
+ ("Backend();", "Backend::Backend() : value(0) {}"),
+ ("explicit Backend(int initial = 0);", "Backend::Backend(int initial) : value(initial) {}"),
+])
+def test_req_qml008_ac02_constructor_prototypes_are_callable_methods(tmp_path, prototype, definition):
+ """Header declaration nodes are missing before the correction, unlike ordinary methods."""
+ paths = corpus(tmp_path, f"class Backend {{public: {prototype} int value;}};", definition)
+ direct = extract_cpp(paths[0])
+ constructors = [node for node in direct["nodes"] if node.get("label") == ".Backend()"]
+ assert len(constructors) == 1 and constructors[0].get("_callable") is True
+ owners = owner_edges(direct, constructors[0])
+ assert len(owners) == 1
+ assert next(node for node in direct["nodes"] if node["id"] == owners[0]["source"])["label"] == "Backend"
+
+
+@pytest.mark.parametrize("namespace,qualification", [
+ ("", "Backend::Backend"),
+ ("Shared", "Shared::Backend::Backend"),
+ ("Shared", "namespace Shared { Backend::Backend"),
+])
+def test_req_qml008_ac02_constructor_definition_retains_id_and_exact_accepted_owner(tmp_path, namespace, qualification):
+ """Explicit scope and header declarations authorize ownership without rewriting a definition ID."""
+ header = "class Backend {public: explicit Backend(int initial); int value;};"
+ if namespace:
+ header = f"namespace {namespace} {{ {header} }}"
+ implementation = qualification + "(int initial) : value(initial) {}"
+ if qualification.startswith("namespace"):
+ implementation += " }"
+ paths = corpus(tmp_path, header, implementation)
+ direct = extract_cpp(paths[1])
+ definition = next(node for node in direct["nodes"] if node.get("_callable") and not node.get("_callable_class"))
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ constructors = [node for node in result["nodes"] if node.get("_callable") and "Backend" in node["label"]
+ and not node.get("_callable_class")]
+ candidate = next(node for node in constructors if node.get("definition_file") == "backend.cpp"
+ or node.get("source_file") == "backend.cpp")
+ assert candidate["id"] == "backend_" + definition["id"].removeprefix(_make_id(_file_stem(paths[1])) + "_")
+ owners = owner_edges(result, candidate)
+ assert len({edge["source"] for edge in owners}) == 1
+ owner = next(node for node in result["nodes"] if node["id"] == owners[0]["source"])
+ assert owner.get("_callable_class") is True and owner["source_file"] == "backend.hpp"
+
+
+@pytest.mark.parametrize("header,implementation", [
+ ("class Backend {public: Backend(double value);};", "Backend::Backend(int value) {}"),
+ ("class Backend;", "Backend::Backend(int value) {}"),
+ ("namespace Other {class Backend {public: Backend(int value);};}", "Foreign::Backend::Backend(int value) {}"),
+ ("class Backend {public: Backend(int value); Backend(const int other);};", "Backend::Backend(int value) {}"),
+ ("class Backend {public: Backend(...);};", "Backend::Backend() {}"),
+ ("class Backend {public: Backend(unsigned int value);};", "Backend::Backend(unsignedint value) {}"),
+])
+def test_req_qml008_ac02_constructor_ownership_requires_complete_matching_unambiguous_proof(tmp_path, header, implementation):
+ """Wrong signatures, incomplete classes, foreign scopes and duplicate signatures cannot invent an owner."""
+ paths = corpus(tmp_path, header, implementation)
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ definition = next(node for node in result["nodes"] if node.get("_callable")
+ and (node.get("source_file") == "backend.cpp" or node.get("definition_file") == "backend.cpp"))
+ assert not owner_edges(result, definition)
+
+
+def test_req_qml008_ac02_constructor_binding_keeps_unrelated_generic_edges(tmp_path):
+ """An ownership correction must preserve the producer's existing ID and field references."""
+ paths = corpus(tmp_path, "class Backend {public: Backend(int initial); int value;};",
+ "int helper(){return 1;} Backend::Backend(int initial) : value(initial) {value=helper();}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ functions = {node["label"]: node for node in result["nodes"] if node.get("_callable") and not node.get("_callable_class")}
+ constructor = next(node for node in functions.values() if "Backend" in node["label"])
+ assert any(edge["source"] == constructor["id"] and edge["target"] == functions["helper()"]["id"]
+ and edge["relation"] == "calls" for edge in result["edges"])
+ before = copy.deepcopy(result)
+ repeated = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert repeated == before
+
+
+@pytest.mark.parametrize("corruption", ["wrong_line", "foreign_header", "inferred_parent", "conflicting_parent", "corrupt_transport", "foreign_edge", "wrong_edge_line", "corrupt_span", "not_callable", "reversed_columns", "conflicting_contains"])
+def test_req_qml008_ac02_constructor_join_rejects_foreign_or_corrupt_accepted_proof(tmp_path, corruption):
+ """Neither lexical names nor a collision replace exact source and extracted-parent authority."""
+ paths = corpus(tmp_path, "class Backend {public: Backend(int initial);};", "Backend::Backend(int initial) {}")
+ header, implementation = (extract_cpp(path) for path in paths)
+ nodes = copy.deepcopy(header["nodes"] + implementation["nodes"])
+ edges = copy.deepcopy(header["edges"] + implementation["edges"])
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition")
+ prototype = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "declaration")
+ if corruption == "wrong_line":
+ definition["source_location"] = "L99"
+ elif corruption == "foreign_header":
+ prototype["source_file"] = "foreign.hpp"
+ elif corruption == "inferred_parent":
+ for edge in edges:
+ if edge.get("relation") == "method":
+ edge["confidence"] = "INFERRED"
+ elif corruption == "conflicting_parent":
+ edges.append({"source": "foreign_class", "target": definition["id"], "relation": "method", "confidence": "EXTRACTED"})
+ elif corruption in {"foreign_edge", "wrong_edge_line"}:
+ for edge in edges:
+ if edge.get("relation") == "method":
+ edge["source_file" if corruption == "foreign_edge" else "source_location"] = "foreign.hpp" if corruption == "foreign_edge" else "L99"
+ elif corruption == "corrupt_span":
+ definition["metadata"]["cpp_constructor"]["span"]["start_row"] = True
+ elif corruption == "not_callable":
+ definition["_callable"] = False
+ elif corruption == "reversed_columns":
+ definition["metadata"]["cpp_constructor"]["span"].update(start_column=5, end_column=0)
+ elif corruption == "conflicting_contains":
+ other_path = tmp_path / "other.hpp"
+ other_path.write_text("class Other {};", encoding="utf-8")
+ other = next(node for node in extract_cpp(other_path)["nodes"] if node.get("_callable_class"))
+ nodes.append(other)
+ edges.append({"source": other["id"], "target": definition["id"], "relation": "contains",
+ "confidence": "EXTRACTED", "source_file": definition["source_file"]})
+ else:
+ definition["metadata"]["cpp_constructor"]["owner_b64"] = "invalid!"
+ bind_cpp_constructors(nodes, edges)
+ assert not definition["metadata"]["cpp_constructor"].get("owner_bound")
+ assert not constructor_merge_allowed([prototype, definition])
+
+
+def test_req_qml008_ac02_long_qualified_owner_stays_bounded_through_metadata_sanitation(tmp_path):
+ """Oversized identifiers cannot be silently truncated into another constructor owner."""
+ namespace = "N" * 385
+ paths = corpus(tmp_path, f"namespace {namespace} {{ class Backend {{public: Backend(int initial);}}; }}",
+ f"{namespace}::Backend::Backend(int initial) {{}}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ definition = next(node for node in result["nodes"] if node.get("_callable") and node["source_file"] == "backend.cpp")
+ assert not owner_edges(result, definition)
+
+
+def test_req_qml008_ac02_corrupted_owner_cannot_relabel_another_actual_constructor(tmp_path):
+ """Changing only copied owner transport must never bind an Impostor body to Genuine."""
+ paths = corpus(tmp_path, "class Genuine {public: Genuine(int value);};", "Impostor::Impostor(int value) {}")
+ results = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in results for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in results for edge in result["edges"]])
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition")
+ definition["metadata"]["cpp_constructor"]["owner_b64"] = base64.b64encode(b"Genuine").decode()
+ bind_cpp_constructors(nodes, edges)
+ assert not definition["metadata"]["cpp_constructor"].get("owner_bound")
+ assert not owner_edges({"edges": edges}, definition)
+
+
+def test_req_qml008_ac02_reference_parameter_names_do_not_change_constructor_type_proof(tmp_path):
+ """Different parameter names retain the same reference type without erasing word tokens."""
+ paths = corpus(tmp_path, "class Backend {public: Backend(const int &left);};", "Backend::Backend(const int &right) {}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ definition = next(node for node in result["nodes"] if node.get("definition_file") == "backend.cpp")
+ assert owner_edges(result, definition)
+
+
+def test_req_qml008_ac02_generic_constructor_facts_keep_original_bom_crlf_unicode_spans(tmp_path):
+ """Class, prototype and implementation metadata spans all address the original disk bytes."""
+ header = "\ufeff// caf\u00e9 \u96ea\r\nclass Backend {\r\npublic:\r\n explicit Backend(const int &left);\r\n};\r\n"
+ implementation = "\ufeff// caf\u00e9 \u96ea\r\nBackend::Backend(const int &right) {}\r\n"
+ paths = [tmp_path / "backend.hpp", tmp_path / "backend.cpp"]
+ for path, source in zip(paths, (header, implementation)):
+ path.write_bytes(source.encode("utf-8"))
+ for path, expected in zip(paths, ((b"class Backend {\r\npublic:\r\n explicit Backend(const int &left);\r\n}",
+ b"explicit Backend(const int &left);"), (b"Backend::Backend(const int &right) {}",))):
+ result = extract_cpp(path)
+ slices = []
+ for node in result["nodes"]:
+ for key in ("cpp_class", "cpp_constructor"):
+ fact = node.get("metadata", {}).get(key)
+ if fact:
+ span = fact["span"]
+ slices.append(path.read_bytes()[span["start_byte"]:span["end_byte"]])
+ assert slices == list(expected)
+
+
+def test_req_qml008_ac02_corrupted_namespace_owner_cannot_escape_original_lexical_scope(tmp_path):
+ """A copied owner field cannot redirect a Shared constructor into an accepted Other class."""
+ paths = [tmp_path / "shared.hpp", tmp_path / "other.hpp", tmp_path / "backend.cpp"]
+ sources = ["namespace Shared {class Backend {public: Backend(int value);};}",
+ "namespace Other {class Backend {public: Backend(int value);};}",
+ "namespace Shared { Backend::Backend(int value) {} }"]
+ for path, source in zip(paths, sources):
+ path.write_text(source, encoding="utf-8")
+ results = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in results for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in results for edge in result["edges"]])
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition")
+ definition["metadata"]["cpp_constructor"]["owner_b64"] = base64.b64encode(b"Other::Backend").decode()
+ bind_cpp_constructors(nodes, edges)
+ assert not definition["metadata"]["cpp_constructor"].get("owner_bound")
+ assert not owner_edges({"edges": edges}, definition)
+
+
+@pytest.mark.parametrize("source", ["namespace {class Backend {public: Backend(int value);}; Backend::Backend(int value) {}}",
+ "struct {int value;} unnamed; int helper(){return 1;}"])
+def test_req_qml008_ac02_anonymous_cpp_scopes_do_not_break_generic_extraction(tmp_path, source):
+ """Unsupported anonymous constructor scopes keep ordinary C++ output without producer exceptions."""
+ path = tmp_path / "anonymous.cpp"
+ path.write_text(source, encoding="utf-8")
+ result = extract_cpp(path)
+ assert not result.get("error") and not result.get("parse_errors")
+ assert result["nodes"]
+ assert not any(node.get("metadata", {}).get("cpp_constructor") for node in result["nodes"])
diff --git a/tests/test_cpp_overload_identity.py b/tests/test_cpp_overload_identity.py
new file mode 100644
index 0000000000..7c07a14011
--- /dev/null
+++ b/tests/test_cpp_overload_identity.py
@@ -0,0 +1,203 @@
+"""INC-QML-15: accepted constructor signatures retain independent source owners."""
+from __future__ import annotations
+
+import copy
+import base64
+import hashlib
+
+import pytest
+
+from graphify.extract import extract, extract_cpp
+from graphify.extractors.cpp_constructors import bind_cpp_constructors
+
+
+def constructors(result):
+ """Select producer contracts rather than labels shared by every overload."""
+ return [node for node in result["nodes"]
+ if node.get("metadata", {}).get("cpp_constructor")]
+
+
+def source_pair(root, declarations="Backend(); Backend(int value);", definitions=None, namespace=""):
+ """Sibling files establish exact class ownership without executing constructors."""
+ header = f"class Backend {{public: {declarations}}};"
+ body = definitions or "Backend::Backend() {}\nBackend::Backend(int value) {}\n"
+ if namespace:
+ header = f"namespace {namespace} {{ {header} }}"
+ body = body.replace("Backend::Backend", f"{namespace}::Backend::Backend")
+ paths = [root / "backend.hpp", root / "backend.cpp"]
+ for path, source in zip(paths, (header, '#include "backend.hpp"\n' + body)):
+ path.write_bytes(source.encode())
+ return paths
+
+
+@pytest.mark.parametrize("namespace", ["", "Shared", "Shared::Inner"])
+def test_req_qml008_ac02_overload_producer_ids_spans_and_exact_declaration_merge(tmp_path, namespace):
+ """Each real definition and prototype owns its signature, line and canonical containment."""
+ paths = source_pair(tmp_path, namespace=namespace)
+ direct = [extract_cpp(path) for path in paths]
+ assert [len(constructors(result)) for result in direct] == [2, 2]
+ for path, result in zip(paths, direct):
+ assert len({node["id"] for node in constructors(result)}) == 2
+ for node in constructors(result):
+ fact = node["metadata"]["cpp_constructor"]
+ span = fact["span"]
+ original = path.read_bytes()[span["start_byte"]:span["end_byte"]]
+ assert b"Backend(" in original and node["source_location"] == f"L{span['start_row'] + 1}"
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ merged = constructors(result)
+ assert len(merged) == 2
+ assert {node["definition_location"] for node in merged} == {"L2", "L3"}
+ owner = next(node for node in result["nodes"] if node.get("_callable_class"))
+ assert all(any(edge["source"] == owner["id"] and edge["target"] == node["id"]
+ and edge["relation"] == "method" for edge in result["edges"]) for node in merged)
+
+
+def test_req_qml008_ac03_inline_delegating_overloads_have_independent_fact_locations(tmp_path):
+ """Delegation is a distinct constructor body; it does not select the delegated overload target."""
+ path = tmp_path / "inline.cpp"
+ path.write_bytes(b"class Backend {public:\n Backend() : Backend(0) {}\n Backend(int value) {}\n};")
+ result = extract_cpp(path)
+ found = constructors(result)
+ assert len(found) == 2 and len({node["id"] for node in found}) == 2
+ assert {node["source_location"] for node in found} == {"L2", "L3"}
+ assert all(node["metadata"]["cpp_constructor"]["role"] == "definition" for node in found)
+
+
+def test_req_qml008_ac02_corrupt_signature_never_binds_another_overload(tmp_path):
+ """A forged signature copied from a sibling overload cannot acquire its source authority."""
+ paths = source_pair(tmp_path)
+ direct = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in direct for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in direct for edge in result["edges"]])
+ definitions = [node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition"]
+ assert len(definitions) == 2
+ definitions[0]["metadata"]["cpp_constructor"]["signature"] = definitions[1]["metadata"]["cpp_constructor"]["signature"]
+ bind_cpp_constructors(nodes, edges)
+ assert definitions[0]["metadata"]["cpp_constructor"].get("owner_bound") is not True
+
+
+@pytest.mark.parametrize("declared,defined", [
+ ("", "void"), ("int value = 0", "const int renamed"), ("unsigned", "unsigned int other"),
+ ("const int &left", "int const &right"), ("int * const pointer", "int *other"),
+ ("QObject *parent = nullptr", "QObject *renamed"), ("const Backend ©", "Backend const &other"),
+ ("int &&value", "int &&other"), ("unsigned long long", "long unsigned long number"),
+])
+def test_req_qml008_ac02_accepted_parameter_equivalence_joins_exact_original_spans(tmp_path, declared, defined):
+ """Parameter names/defaults and value-only CV cannot split one real constructor signature."""
+ paths = source_pair(tmp_path, f"Backend({declared});", f"Backend::Backend({defined}) {{}}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ found = constructors(result)
+ assert len(found) == 1 and found[0]["definition_file"] == "backend.cpp"
+ fact = found[0]["metadata"]["cpp_constructor"]
+ span = fact["definition_span"]
+ assert paths[1].read_bytes()[span["start_byte"]:span["end_byte"]] == f"Backend::Backend({defined}) {{}}".encode()
+
+
+@pytest.mark.parametrize("parameters", [
+ ("int value", "double value"), ("int *value", "const int *value"),
+ ("int &value", "const int &value"), ("int &value", "int &&value"),
+ ("int **value", "int *const *value"), ("signed char value", "unsigned char value"),
+])
+def test_req_qml008_ac02_distinct_pointer_reference_and_builtin_types_never_collapse(tmp_path, parameters):
+ """Pointee CV, reference kind and builtin signedness are meaningful signature distinctions."""
+ decl = " ".join(f"Backend({value});" for value in parameters)
+ body = "\n".join(f"Backend::Backend({value}) {{}}" for value in parameters)
+ result = extract(source_pair(tmp_path, decl, body), root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert len(constructors(result)) == 2 and len({node["id"] for node in constructors(result)}) == 2
+ assert all(node.get("definition_file") == "backend.cpp" for node in constructors(result))
+
+
+@pytest.mark.parametrize("parameter", ["Alias value", "std::vector value", "void (*callback)(int)",
+ "int array[3]", "...", "Unknown &value"])
+def test_req_qml008_ac03_unsupported_signatures_keep_visible_distinct_occurrences_without_owner(tmp_path, parameter):
+ """Insufficient type/alias authority cannot bind a convenient constructor prototype."""
+ paths = source_pair(tmp_path, f"Backend({parameter});", f"Backend::Backend({parameter}) {{}}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ found = constructors(result)
+ assert len(found) == 2
+ definition = next(node for node in found if node["source_file"] == "backend.cpp")
+ assert definition["metadata"]["cpp_constructor"]["ambiguous"] is True
+ assert not any(edge["target"] == definition["id"] and edge["relation"] == "method" for edge in result["edges"])
+
+
+@pytest.mark.parametrize("corrupt", ["signature_b64", "signature", "signature_and_transport", "scope", "id", "span", "inline_parent"])
+def test_req_qml008_ac03_direct_binding_rejects_corrupted_identity_transport(tmp_path, corrupt):
+ """Source contracts, salted identity and real class containment must agree at the direct join."""
+ paths = source_pair(tmp_path)
+ direct = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in direct for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in direct for edge in result["edges"]])
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition")
+ fact = definition["metadata"]["cpp_constructor"]
+ if corrupt == "signature_b64":
+ fact["signature_b64"] = "invalid!"
+ elif corrupt == "signature":
+ fact["signature"] = "0" * 64
+ elif corrupt == "signature_and_transport":
+ fact["signature_b64"] = base64.b64encode(b"ctor2\0double").decode()
+ fact["signature"] = hashlib.sha256(b"ctor2\0double").hexdigest()
+ elif corrupt == "scope":
+ fact["scope_b64"] = base64.b64encode(b"Wrong").decode()
+ elif corrupt == "id":
+ definition["id"] = "impostor_cppctor_" + fact["signature"]
+ elif corrupt == "span":
+ fact["span"]["start_row"] += 1
+ else:
+ fact["owner_bound"] = True
+ edges.append({"source": "foreign", "target": definition["id"], "relation": "method",
+ "confidence": "EXTRACTED", "source_file": definition["source_file"],
+ "source_location": definition["source_location"]})
+ bind_cpp_constructors(nodes, edges)
+ assert fact.get("owner_bound") is not True
+
+
+def test_req_qml008_ac02_constructor_ids_survive_overload_addition_removal_and_parameter_rename(tmp_path):
+ """Signature identity is independent of overload cardinality and neighboring source positions."""
+ paths = source_pair(tmp_path, "Backend(int value);", "Backend::Backend(int value) {}")
+ first = next(node for node in constructors(extract_cpp(paths[1])))
+ paths = source_pair(tmp_path, "Backend(); Backend(int other);", "Backend::Backend() {}\nBackend::Backend(int renamed) {}")
+ later = next(node for node in constructors(extract_cpp(paths[1]))
+ if node["metadata"]["cpp_constructor"]["signature"] == first["metadata"]["cpp_constructor"]["signature"])
+ assert later["id"] == first["id"]
+
+
+def test_req_qml008_ac02_constructor_and_unrelated_method_ids_keep_independent_generic_calls(tmp_path):
+ """Every body owns its helper call; non-constructor function/member IDs retain upstream identity."""
+ paths = source_pair(tmp_path, "Backend(); Backend(int); int work();",
+ "int helper(){return 1;}\nBackend::Backend() {helper();}\nBackend::Backend(int value) {helper();}\nint Backend::work(){return helper();}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ helper = next(node for node in result["nodes"] if node["label"] == "helper()")
+ method = next(node for node in result["nodes"] if node["label"] == ".work()")
+ assert method["id"] == "backend_backend_work"
+ assert all(any(edge["source"] == node["id"] and edge["target"] == helper["id"] and edge["relation"] == "calls"
+ for edge in result["edges"]) for node in constructors(result) + [method])
+
+
+@pytest.mark.parametrize("count", [51, 64])
+def test_req_qml008_ac02_signature_authority_keeps_arguments_beyond_display_list_limit(tmp_path, count):
+ """A differing last parameter must survive the graph's fifty-item display bound."""
+ shared = [f"int value{index}" for index in range(count - 1)]
+ parameters = [", ".join(shared + ["int final"]), ", ".join(shared + ["double final"])]
+ paths = source_pair(tmp_path, " ".join(f"Backend({value});" for value in parameters),
+ "\n".join(f"Backend::Backend({value}) {{}}" for value in parameters))
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ found = constructors(result)
+ assert len(found) == 2 and len({node["id"] for node in found}) == 2
+ assert all(node.get("definition_file") == "backend.cpp" for node in found)
+
+
+def test_req_qml008_ac03_parameter_overflow_cannot_turn_prefix_into_accepted_signature(tmp_path):
+ """Sixty-five parameters retain source occurrences while refusing truncated type proof."""
+ parameter = ", ".join(f"int value{index}" for index in range(65))
+ result = extract(source_pair(tmp_path, f"Backend({parameter});", f"Backend::Backend({parameter}) {{}}"),
+ root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert len(constructors(result)) == 2
+ assert all(node["metadata"]["cpp_constructor"]["ambiguous"] for node in constructors(result))
+
+
+@pytest.mark.parametrize("qualifier", ["const", "volatile", "const volatile"])
+def test_req_qml008_ac03_qualified_void_cannot_authorize_zero_argument_constructor(tmp_path, qualifier):
+ """Only sole unnamed unqualified void represents an empty parameter list."""
+ paths = source_pair(tmp_path, "Backend();", f"Backend::Backend({qualifier} void) {{}}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not any(node.get("definition_file") for node in constructors(result))
diff --git a/tests/test_cpp_overload_source_integrity.py b/tests/test_cpp_overload_source_integrity.py
new file mode 100644
index 0000000000..07e13eb6c9
--- /dev/null
+++ b/tests/test_cpp_overload_source_integrity.py
@@ -0,0 +1,127 @@
+"""INC-QML-15: constructor joins consume accepted file and class source bounds."""
+from __future__ import annotations
+
+import copy
+
+import pytest
+
+from graphify.extract import extract_cpp
+from graphify.extractors.cpp_constructors import bind_cpp_constructors, constructor_merge_allowed
+from tests.test_cpp_overload_identity import constructors, source_pair
+
+
+def direct_pair(root, *, padding=False):
+ """Retain real extracted containment while mutating only transported source proof."""
+ paths = source_pair(root, "Backend(int);", "Backend::Backend(int) {}")
+ if padding:
+ paths[0].write_bytes(paths[0].read_bytes()
+ + b" void unrelated_function_with_enough_source_bytes_to_hold_fake_span() {}")
+ extracted = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in extracted for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in extracted for edge in result["edges"]])
+ found = constructors({"nodes": nodes})
+ prototype = next(node for node in found if node["metadata"]["cpp_constructor"]["role"] == "declaration")
+ definition = next(node for node in found if node["metadata"]["cpp_constructor"]["role"] == "definition")
+ return paths, nodes, edges, prototype, definition
+
+
+def assert_unbound(nodes, edges, definition):
+ """Reject canonical merge; the direct header's existing same-ID method edge remains a source fact."""
+ bind_cpp_constructors(nodes, edges)
+ assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True
+ assert not constructor_merge_allowed(constructors({"nodes": nodes}))
+
+
+@pytest.mark.parametrize("role", ["declaration", "definition"])
+def test_req_qml008_ac03_out_of_file_constructor_transport_cannot_bind(tmp_path, role):
+ """A plausible same-line signature moved beyond the accepted file size has no source authority."""
+ _, nodes, edges, prototype, definition = direct_pair(tmp_path)
+ candidate = prototype if role == "declaration" else definition
+ span = candidate["metadata"]["cpp_constructor"]["span"]
+ span["start_byte"] += 1_000_000
+ span["end_byte"] += 1_000_000
+ assert_unbound(nodes, edges, definition)
+
+
+def test_req_qml008_ac03_in_file_prototype_outside_class_cannot_bind(tmp_path):
+ """A forged prototype on an unrelated same-line function remains inside the file, outside its class."""
+ paths, nodes, edges, prototype, definition = direct_pair(tmp_path, padding=True)
+ original_class_end = paths[0].read_bytes().index(b" void unrelated")
+ span = prototype["metadata"]["cpp_constructor"]["span"]
+ width = span["end_byte"] - span["start_byte"]
+ start = original_class_end + 6
+ span.update(start_byte=start, end_byte=start + width, start_column=start, end_column=start + width)
+ assert span["end_byte"] <= len(paths[0].read_bytes())
+ assert_unbound(nodes, edges, definition)
+
+
+@pytest.mark.parametrize("corruption", ["missing", "duplicate", "size_bool", "size_negative", "version_bool", "foreign_label"])
+def test_req_qml008_ac03_file_bound_transport_cannot_authorize_constructor(tmp_path, corruption):
+ """Absent, conflicting or corrupt admitted file authority cannot validate an otherwise plausible body."""
+ _, nodes, edges, _, definition = direct_pair(tmp_path)
+ file = next(node for node in nodes if node["label"] == "backend.cpp")
+ proof = file["metadata"]["cpp_constructor_types"]
+ if corruption == "missing":
+ file["metadata"].pop("cpp_constructor_types")
+ elif corruption == "duplicate":
+ nodes.append(copy.deepcopy(file))
+ elif corruption == "size_bool":
+ proof["source_size"] = True
+ elif corruption == "size_negative":
+ proof["source_size"] = -1
+ elif corruption == "version_bool":
+ proof["contract_version"] = True
+ else:
+ file["label"] = "foreign.cpp"
+ assert_unbound(nodes, edges, definition)
+
+
+@pytest.mark.parametrize("inline", [False, True])
+def test_req_qml008_ac03_out_of_file_class_span_cannot_authorize_constructor(tmp_path, inline):
+ """A complete class moved beyond its admitted file cannot authorize inline or out-of-line bodies."""
+ if inline:
+ path = tmp_path / "inline.cpp"
+ path.write_bytes(b"class Backend {public: Backend(int) {}};")
+ result = extract_cpp(path)
+ nodes, edges = copy.deepcopy(result["nodes"]), copy.deepcopy(result["edges"])
+ definition = constructors({"nodes": nodes})[0]
+ else:
+ _, nodes, edges, _, definition = direct_pair(tmp_path)
+ owner = next(node for node in nodes if node.get("_callable_class"))
+ span = owner["metadata"]["cpp_class"]["span"]
+ span["start_byte"] += 1_000_000
+ span["end_byte"] += 1_000_000
+ assert_unbound(nodes, edges, definition)
+
+
+def test_req_qml008_ac02_original_file_and_class_bounds_accept_without_source_rereads(tmp_path, monkeypatch):
+ """Accepted EOF body bounds bind from existing AST facts even when corpus reads are unavailable."""
+ from pathlib import Path
+
+ paths, nodes, edges, prototype, definition = direct_pair(tmp_path)
+ assert definition["metadata"]["cpp_constructor"]["span"]["end_byte"] == len(paths[1].read_bytes())
+
+ def unavailable_read(*args, **kwargs):
+ raise AssertionError("Constructor binding must consume existing admitted AST proof")
+
+ monkeypatch.setattr(Path, "read_bytes", unavailable_read)
+ monkeypatch.setattr(Path, "read_text", unavailable_read)
+ bind_cpp_constructors(nodes, edges)
+ assert definition["metadata"]["cpp_constructor"]["owner_bound"] is True
+ assert constructor_merge_allowed([prototype, definition])
+
+
+@pytest.mark.parametrize("prefix", [b"#include HEADER_MACRO\n", b"#include \n" * 51])
+def test_req_qml008_ac02_builtin_signature_file_bounds_independent_of_include_completeness(tmp_path, prefix):
+ """Conservative SDK include exclusion cannot remove accepted builtin constructor equivalence."""
+ paths = source_pair(tmp_path, "Backend(int);", "Backend::Backend(int) {}")
+ paths[1].write_bytes(prefix + paths[1].read_bytes())
+ result = [extract_cpp(path) for path in paths]
+ nodes = [node for item in result for node in item["nodes"]]
+ edges = [edge for item in result for edge in item["edges"]]
+ prototype, definition = constructors({"nodes": nodes})
+ file = next(node for node in nodes if node["label"] == "backend.cpp")
+ assert file["metadata"]["cpp_constructor_types"]["complete"] is False
+ bind_cpp_constructors(nodes, edges)
+ assert definition["metadata"]["cpp_constructor"]["owner_bound"] is True
+ assert constructor_merge_allowed([prototype, definition])
diff --git a/tests/test_cpp_overload_type_authority.py b/tests/test_cpp_overload_type_authority.py
new file mode 100644
index 0000000000..c61d23f5ab
--- /dev/null
+++ b/tests/test_cpp_overload_type_authority.py
@@ -0,0 +1,216 @@
+"""INC-QML-15: named SDK constructor signatures require admitted type authority."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract, extract_cpp
+from graphify.extractors.cpp_constructors import bind_cpp_constructors
+from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority
+from graphify.paths import load_node_link_graph
+from tests.test_cpp_overload_identity import constructors, source_pair
+
+
+def project(root, shadow="using QObject = int;", include=True, transitive=False):
+ """Only already admitted AST inputs participate; include text executes nothing."""
+ paths = source_pair(root, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}")
+ alias = root / "alias.hpp"
+ alias.write_bytes(shadow.encode())
+ paths.append(alias)
+ dependency = alias.name
+ if transitive:
+ bridge = root / "bridge.hpp"
+ bridge.write_bytes(b'#include "alias.hpp"\n')
+ paths.append(bridge)
+ dependency = bridge.name
+ if include:
+ paths[1].write_bytes(f'#include "{dependency}"\n'.encode() + paths[1].read_bytes())
+ return paths
+
+
+@pytest.mark.parametrize("shadow", ["using QObject = int;", "typedef int QObject;", "class QObject;",
+ "struct QObject {};", "#if FEATURE\nusing QObject = int;\n#endif\n"])
+@pytest.mark.parametrize("transitive", [False, True])
+def test_req_qml008_ac03_included_sdk_shadow_cannot_bind_same_spelled_constructor(tmp_path, shadow, transitive):
+ """Different source type identities must not merge merely because both parameters spell QObject."""
+ result = extract(project(tmp_path, shadow, transitive=transitive), root=tmp_path, cache_root=tmp_path, parallel=False)
+ definition = next(node for node in constructors(result) if node["source_file"] == "backend.cpp")
+ assert definition["metadata"]["cpp_constructor"]["type_authorized"] is False
+ assert not any(edge["target"] == definition["id"] and edge["relation"] == "method" for edge in result["edges"])
+
+
+@pytest.mark.parametrize("include,shadow", [(False, "using QObject = int;"),
+ (True, "namespace Unrelated {using QObject = int;}"),
+ (True, "class Other {public: using QObject = int;};"),
+ (True, "void ordinary(){using QObject = int;}"),
+ (True, "using Other = int;")])
+def test_req_qml008_ac02_unincluded_or_unrelated_scope_cannot_shadow_sdk_constructor(tmp_path, include, shadow):
+ """Available but invisible aliases do not invalidate an otherwise exact constructor."""
+ result = extract(project(tmp_path, shadow, include=include), root=tmp_path, cache_root=tmp_path, parallel=False)
+ found = constructors(result)
+ assert len(found) == 1 and found[0]["definition_file"] == "backend.cpp"
+
+
+@pytest.mark.parametrize("corruption", ["missing", "complete", "include_encoding", "include_bound", "scope_encoding", "span", "version"])
+def test_req_qml008_ac03_direct_constructor_dependency_proof_rejects_corruption(tmp_path, corruption):
+ """Malformed admitted transport never turns a hidden source shadow into SDK authority."""
+ paths = project(tmp_path)
+ direct = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in direct for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in direct for edge in result["edges"]])
+ file = next(node for node in nodes if node["label"] == "alias.hpp")
+ fact = file["metadata"]["cpp_constructor_types"]
+ if corruption == "missing":
+ del file["metadata"]["cpp_constructor_types"]
+ elif corruption == "complete":
+ fact["complete"] = False
+ elif corruption == "include_encoding":
+ cpp_file = next(node for node in nodes if node["label"] == "backend.cpp")
+ cpp_file["metadata"]["cpp_constructor_types"]["includes"][0]["literal_b64"] = "invalid!"
+ elif corruption == "include_bound":
+ fact["includes"] = [{"literal_b64": "YQ==", "end_byte": 0}] * 51
+ elif corruption == "scope_encoding":
+ fact["shadows"][0]["scope_b64"] = "invalid!"
+ elif corruption == "span":
+ fact["shadows"][0]["start_byte"] = True
+ else:
+ fact["contract_version"] = True
+ bind_cpp_constructors(nodes, edges)
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition")
+ assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True
+
+
+def test_req_qml008_ac03_same_class_later_alias_shadows_constructor_parameter_clause(tmp_path):
+ """Whole-class lookup cannot borrow SDK identity from a later member alias."""
+ paths = source_pair(tmp_path, "Backend(QObject *parent); using QObject = int;", "Backend::Backend(QObject *parent) {}")
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not any(node.get("definition_file") for node in constructors(result))
+
+
+def test_req_qml008_ac03_include_walk_overflow_cannot_hide_sdk_shadow(tmp_path):
+ """A long admitted chain rejects authority before bounded traversal can discard an alternative."""
+ paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}")
+ paths[1].write_bytes(b'#include "dependency0.hpp"\n' + paths[1].read_bytes())
+ for index in range(129):
+ dependency = tmp_path / f"dependency{index}.hpp"
+ dependency.write_bytes(f'#include "dependency{index + 1}.hpp"\n'.encode() if index < 128 else b"using QObject = int;")
+ paths.append(dependency)
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not any(node.get("definition_file") for node in constructors(result))
+
+
+def test_req_qml008_ac02_direct_type_proof_survives_sanitation_export_reload(tmp_path):
+ """Producer provenance remains authoritative after the real graph serialization boundary."""
+ paths = project(tmp_path, "using Other = int;")
+ direct = extract_cpp(paths[2])
+ assert next(node for node in direct["nodes"] if node["label"] == "alias.hpp")["metadata"]["cpp_constructor_types"]["complete"]
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(output))
+ reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ nodes = [{"id": identity, **data} for identity, data in reloaded.nodes(data=True)]
+ authority = ConstructorTypeAuthority(nodes)
+ candidate = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor"))
+ assert authority.authorized(candidate, candidate["metadata"]["cpp_constructor"])
+
+
+@pytest.mark.parametrize("include", ['"unavailable.hpp"', '"../../unadmitted.hpp"', '"C:/private.hpp"', 'HEADER_MACRO'])
+def test_req_qml008_ac03_unknown_quoted_dependency_never_authorizes_sdk_signature(tmp_path, include):
+ """Missing, escaping, absolute and dynamically expanded headers grant no compiler type identity."""
+ paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}")
+ paths[1].write_bytes(f"#include {include}\n".encode() + paths[1].read_bytes())
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not any(node.get("definition_file") for node in constructors(result))
+
+
+def test_req_qml008_ac02_shadow_transport_spans_address_original_bom_crlf_unicode_bytes(tmp_path):
+ """Encoded dependency decisions retain spans against the bytes actually read from disk."""
+ path = tmp_path / "alias.hpp"
+ path.write_bytes('\ufeff// caf\u00e9 \u96ea\r\n#include "bridge.hpp"\r\nnamespace Nested { using QObject = int; }\r\n'.encode())
+ result = extract_cpp(path)
+ fact = next(node for node in result["nodes"] if node["label"] == "alias.hpp")["metadata"]["cpp_constructor_types"]
+ original = path.read_bytes()
+ assert fact["source_size"] == len(original)
+ assert len(fact["includes"]) == len(fact["shadows"]) == 1
+ for items, expected in ((fact["includes"], b'#include "bridge.hpp"\r\n'), (fact["shadows"], b"using QObject = int;")):
+ span = items[0]["span"]
+ assert original[span["start_byte"]:span["end_byte"]] == expected
+
+
+@pytest.mark.parametrize("dependency", ["alias.hpp", "nested/alias.hpp"])
+def test_req_qml008_ac03_angle_local_shadow_cannot_grant_sdk_constructor_identity(tmp_path, dependency):
+ """An angle include may name an admitted source alias; compiler search order is unavailable."""
+ paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}")
+ header = tmp_path / dependency
+ header.parent.mkdir(parents=True, exist_ok=True)
+ header.write_bytes(b"using QObject = int;")
+ paths.append(header)
+ # Even an admitted nested basename is a potential include-directory target.
+ literal = header.name
+ paths[1].write_bytes(f"#include <{literal}>\n".encode() + paths[1].read_bytes())
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not any(node.get("definition_file") for node in constructors(result))
+ definition = next(node for node in constructors(result) if node["source_file"] == "backend.cpp")
+ assert definition["metadata"]["cpp_constructor"]["type_authorized"] is False
+
+
+def test_req_qml008_ac02_external_sdk_angle_keeps_unincluded_alias_independent(tmp_path):
+ """An unavailable Qt SDK header does not import an unrelated admitted alias file."""
+ paths = project(tmp_path, include=False)
+ paths[1].write_bytes(b"#include \n#include \n" + paths[1].read_bytes())
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ found = constructors(result)
+ assert len(found) == 1 and found[0]["definition_file"] == "backend.cpp"
+
+
+@pytest.mark.parametrize("corruption", ["missing", "corrupt", "duplicate"])
+def test_req_qml008_ac03_angle_candidate_rejects_missing_corrupt_or_duplicate_authority(tmp_path, corruption):
+ """An admitted angle candidate cannot vanish from the shadow check through damaged metadata."""
+ paths = project(tmp_path, include=False)
+ paths[1].write_bytes(b"#include \n" + paths[1].read_bytes())
+ results = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in results for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in results for edge in result["edges"]])
+ candidate = next(node for node in nodes if node["label"] == "alias.hpp")
+ if corruption == "missing":
+ candidate["metadata"].pop("cpp_constructor_types")
+ elif corruption == "corrupt":
+ candidate["metadata"]["cpp_constructor_types"]["complete"] = False
+ else:
+ nodes.append(copy.deepcopy(candidate))
+ bind_cpp_constructors(nodes, edges)
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition")
+ assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True
+
+
+@pytest.mark.parametrize("kind", ["missing", "invalid", "overflow"])
+def test_req_qml008_ac03_angle_include_transport_limits_reject_sdk_identity(tmp_path, kind):
+ """Missing delimiter authority or truncated include decisions grant no SDK signature proof."""
+ paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}")
+ prefix = b"#include \n" * (51 if kind == "overflow" else 1)
+ paths[1].write_bytes(prefix + paths[1].read_bytes())
+ results = [extract_cpp(path) for path in paths]
+ nodes = copy.deepcopy([node for result in results for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in results for edge in result["edges"]])
+ cpp = next(node for node in nodes if node["label"] == "backend.cpp")
+ include = cpp["metadata"]["cpp_constructor_types"]["includes"][0]
+ if kind == "missing":
+ include.pop("kind")
+ elif kind == "invalid":
+ include["kind"] = "expanded"
+ bind_cpp_constructors(nodes, edges)
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_constructor", {}).get("role") == "definition")
+ assert definition["metadata"]["cpp_constructor"].get("owner_bound") is not True
+
+
+def test_req_qml008_ac03_escaping_angle_literal_cannot_establish_compiler_include_search(tmp_path):
+ """A parent traversal has no accepted compiler include root at this analysis boundary."""
+ paths = source_pair(tmp_path, "Backend(QObject *parent);", "Backend::Backend(QObject *parent) {}")
+ paths[1].write_bytes(b"#include <../alias.hpp>\n" + paths[1].read_bytes())
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not any(node.get("definition_file") for node in constructors(result))
diff --git a/tests/test_cpp_overload_updates.py b/tests/test_cpp_overload_updates.py
new file mode 100644
index 0000000000..854cc5363f
--- /dev/null
+++ b/tests/test_cpp_overload_updates.py
@@ -0,0 +1,222 @@
+"""INC-QML-15: exact overload identity across durable cold/manual/watch updates."""
+from __future__ import annotations
+
+import os
+import stat
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.watch import _rebuild_code
+import graphify.watch as watch
+from graphify.cache import cache_dir
+import graphify.cache as cache
+import graphify.qt_incremental as qt_incremental
+import graphify.extractors.cpp_constructor_signature as constructor_signatures
+from graphify.extractors.base import _make_id
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+from tests.test_qt_overload_consumers import fixture
+
+
+def project(root):
+ """Real Qt constructor bodies and unrelated Python exercise the shared production pipeline."""
+ fixture(root)
+ (root / "keep.py").write_bytes(b"def helper(): return 7\n\ndef retained(): return helper()\n")
+ return [root / "backend.hpp", root / "backend.cpp"]
+
+
+def identities(graph):
+ return {data["metadata"]["cpp_constructor"]["signature"]: identity
+ for identity, data in graph.nodes(data=True) if data.get("metadata", {}).get("cpp_constructor")}
+
+
+def assert_owners(graph):
+ """Each constructor body owns four native mechanisms with its distinct canonical ID."""
+ accepted = set(identities(graph).values())
+ owned = [qt_metadata(data) for _, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") in {"emission", "qml_access", "qml_load", "qml_root"}]
+ assert len(accepted) == 2 and len(owned) == 8
+ assert all(md["status"] == "resolved" and md["owner_id"] in accepted for md in owned)
+ assert {md["owner_id"] for md in owned} == accepted
+
+
+def replace_types(paths, old, new):
+ for path in paths:
+ path.write_bytes(path.read_bytes().replace(old, new))
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac01_ac04_overload_signature_edit_removal_and_repeat_parity(tmp_path, monkeypatch, operation):
+ """Cold/warm/manual/watch retain exact IDs and erase retired signatures and locations."""
+ paths = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold-cache")
+ assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ assert_owners(initial)
+ accepted, kept = identities(initial), unrelated(initial)
+ replace_types(paths, b"int ", b"double ")
+ changed = run(tmp_path, monkeypatch, operation, paths)
+ assert_owners(changed)
+ assert len(set(identities(changed).values()).intersection(accepted.values())) == 1
+ assert not set(accepted.values()).difference(identities(changed).values()).intersection(changed)
+ assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".changed-cache"))
+ assert unrelated(changed) == kept
+
+ # Parameter renaming changes occurrence bytes/spans, never the accepted
+ # signature ID; cached and rebuilt facts must carry the new original ranges.
+ before_rename = identities(changed)
+ replace_types(paths, b"value", b"renamedValue")
+ renamed = run(tmp_path, monkeypatch, operation, paths)
+ assert identities(renamed) == before_rename
+ assert normalized(renamed) == normalized(clean(tmp_path, tmp_path / ".rename-cache"))
+
+ # Remove one declaration and definition rather than hiding it by a filter.
+ header = paths[0].read_bytes().replace(b" Backend(double renamedValue);", b"")
+ paths[0].write_bytes(header)
+ lines = paths[1].read_bytes().splitlines(keepends=True)
+ paths[1].write_bytes(b"".join(line for line in lines if b"Backend::Backend(double" not in line))
+ removed = run(tmp_path, monkeypatch, operation, paths)
+ assert len(identities(removed)) == 1
+ assert len([1 for _, data in removed.nodes(data=True) if qt_metadata(data).get("kind") == "emission"]) == 1
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ assert unrelated(removed) == kept
+ before = (tmp_path / "graphify-out/graph.json").read_bytes()
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed)
+ assert (tmp_path / "graphify-out/graph.json").read_bytes() == before
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("failure_kind", ["readonly", "late_os_error"])
+def test_req_qml011_ac04_overload_publication_failure_retains_cohort_and_retries(tmp_path, monkeypatch, operation, failure_kind):
+ """A real readonly manifest must retain prior graph/identity/checkpoint bytes before recovery."""
+ paths = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert_owners(initial)
+ output = tmp_path / "graphify-out"
+ products = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+ retained = {name: (output / name).read_bytes() for name in products}
+ ast_cache = cache_dir(tmp_path)
+ old_cache = {path: path.read_bytes() for path in ast_cache.rglob("*.json")}
+ assert old_cache # The actual versioned AST cache, not an empty historical filename pattern.
+ replace_types(paths, b"int ", b"double ")
+ manifest = output / "manifest.json"
+ original_mode = manifest.stat().st_mode
+ if failure_kind == "readonly" and os.name != "nt":
+ pytest.skip("Windows readonly destination contract; POSIX late real replacement failure runs separately")
+ actual_replace = os.replace
+ product_replace = watch.os_replace_with_fallback
+ blocker = tmp_path / ".replace-blocker"
+ blocker.mkdir()
+
+ def reject_manifest(source, destination):
+ if destination.name == "manifest.json":
+ return actual_replace(source, blocker) # Actual OS rejection of file-to-directory replacement.
+ return product_replace(source, destination)
+
+ if failure_kind == "readonly":
+ os.chmod(manifest, stat.S_IREAD)
+ else:
+ monkeypatch.setattr(watch, "os_replace_with_fallback", reject_manifest)
+ try:
+ if operation == "manual":
+ with pytest.raises(SystemExit) as failure:
+ run(tmp_path, monkeypatch, operation, paths)
+ assert failure.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=paths, no_cluster=True)
+ assert retained == {name: (output / name).read_bytes() for name in products}
+ assert all(path.read_bytes() == content for path, content in old_cache.items())
+ finally:
+ os.chmod(manifest, original_mode | stat.S_IWRITE)
+ monkeypatch.setattr(watch, "os_replace_with_fallback", product_replace)
+ repaired = run(tmp_path, monkeypatch, operation, paths)
+ assert_owners(repaired)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache"))
+ assert normalized(repaired) != normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac04_cpp_only_overload_mismatch_rejects_native_owner_then_header_repair(tmp_path, monkeypatch, operation):
+ """Unchanged header prototypes cannot become owners of a newly incompatible constructor body."""
+ paths = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert_owners(initial)
+ paths[1].write_bytes(paths[1].read_bytes().replace(b"int renamed", b"double renamed"))
+ mismatch = run(tmp_path, monkeypatch, operation, [paths[1]])
+ emissions = [qt_metadata(data) for _, data in mismatch.nodes(data=True) if qt_metadata(data).get("kind") == "emission"]
+ assert len(emissions) == 2 and sum(md["status"] == "resolved" for md in emissions) == 1
+ assert normalized(mismatch) == normalized(clean(tmp_path, tmp_path / ".mismatch-cache"))
+ paths[0].write_bytes(paths[0].read_bytes().replace(b"int value", b"double value"))
+ repaired = run(tmp_path, monkeypatch, operation, [paths[0]])
+ assert_owners(repaired)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".header-repair-cache"))
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac04_partial_constructor_parse_retains_accepted_identity_and_recovery(tmp_path, monkeypatch, operation):
+ """An actual malformed overloaded C++ file cannot publish a partial new constructor set."""
+ paths = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ retained = {name: (output / name).read_bytes() for name in ("graph.json", "manifest.json", ".qt_analysis.json")}
+ original = paths[1].read_bytes()
+ paths[1].write_bytes(original + b"\nBackend::Backend(double invalid {\n")
+ if operation == "manual":
+ with pytest.raises(SystemExit) as failure:
+ run(tmp_path, monkeypatch, operation, paths)
+ assert failure.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=paths, no_cluster=True)
+ assert retained == {name: (output / name).read_bytes() for name in retained}
+ paths[1].write_bytes(original)
+ restored = run(tmp_path, monkeypatch, operation, paths)
+ assert normalized(restored) == normalized(initial)
+ assert_owners(restored)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac04_schema9_name_id_fixture_migrates_unchanged_sources_to_exact_signatures(tmp_path, monkeypatch, operation):
+ """A same-package prior-ID producer seam supplies historical identity, not fabricated current graph data."""
+ paths = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ current_schema, current_policy = cache._AST_CACHE_SCHEMA, qt_incremental.QT_POLICY_VERSION
+ assert current_schema >= 10 and current_policy >= 14
+ original = {path: path.read_bytes() for path in paths}
+
+ def prior_name_id(class_id, name, *_args):
+ return _make_id(class_id, name)
+
+ # Only historical producer ID and epoch seams differ. Current declaration
+ # authority guards intentionally refuse these old identity contracts; this
+ # proves migration/retirement, not a replay of prior release semantics.
+ with monkeypatch.context() as legacy:
+ legacy.setattr(cache, "_AST_CACHE_SCHEMA", 9)
+ legacy.setattr(qt_incremental, "QT_POLICY_VERSION", 13)
+ legacy.setattr(constructor_signatures, "constructor_id", prior_name_id)
+ legacy.setattr("graphify.extractors.cpp_constructors.constructor_id", prior_name_id)
+ prior = run(tmp_path, monkeypatch, operation)
+ prior_ids = set(identities(prior).values())
+ assert prior_ids and all("_cppctor_" not in identity for identity in prior_ids)
+ prior_cache = cache_dir(tmp_path)
+ assert "-s9" in prior_cache.name and list(prior_cache.rglob("*.json"))
+ migrated = run(tmp_path, monkeypatch, operation, [])
+ assert not prior_ids.intersection(migrated)
+ assert_owners(migrated)
+ assert all("_cppctor_" in identity for identity in identities(migrated).values())
+ assert original == {path: path.read_bytes() for path in paths}
+ assert cache_dir(tmp_path) != prior_cache
+ assert normalized(migrated) == normalized(clean(tmp_path, tmp_path / ".migration-cache"))
+ before = (tmp_path / "graphify-out/graph.json").read_bytes()
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(migrated)
+ assert before == (tmp_path / "graphify-out/graph.json").read_bytes()
diff --git a/tests/test_cpp_qualified_identity.py b/tests/test_cpp_qualified_identity.py
new file mode 100644
index 0000000000..66ad307c3c
--- /dev/null
+++ b/tests/test_cpp_qualified_identity.py
@@ -0,0 +1,160 @@
+"""REQ-QML-008/016/017: qualified native identities survive real publication."""
+from __future__ import annotations
+
+import base64
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract_cpp
+from graphify.extractors.base import _make_id, _file_stem
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_construction_authority import native
+
+
+def classes(result):
+ return {base64.b64decode(node["metadata"]["cpp_class"]["qualified_name_b64"]).decode(): node
+ for node in result["nodes"] if node.get("metadata", {}).get("cpp_class")}
+
+
+@pytest.mark.parametrize("source, names", [
+ ("class Base {}; namespace Public { class Base {}; }", {"Base", "Public::Base"}),
+ ("namespace A_B {class Base {};} namespace A::B {class Base {};}", {"A_B::Base", "A::B::Base"}),
+ ("namespace Public {class Base {};} namespace PUBLIC {class Base {};}", {"Public::Base", "PUBLIC::Base"}),
+ ("class One {public: class Base {};}; class Two {public: class Base {};};",
+ {"One", "One::Base", "Two", "Two::Base"}),
+])
+def test_req_qml008_ac02_qualified_class_bodies_keep_distinct_portable_identities(tmp_path, source, names):
+ path = tmp_path / "backend.h"
+ path.write_text(source, encoding="utf-8")
+ direct = extract_cpp(path)
+ accepted = classes(direct)
+ assert set(accepted) == names
+ assert len({node["id"] for node in accepted.values()}) == len(names)
+ assert all(not node["metadata"]["cpp_class"]["ambiguous"] for node in accepted.values())
+ result = analysis(tmp_path, {"backend.h": source})
+ assert set(classes(result)) == names
+ assert all(str(tmp_path).lower().replace("\\", "_") not in node["id"] for node in classes(result).values())
+ original_ids = {name: node["id"] for name, node in classes(result).items()}
+ shifted = analysis(tmp_path, {"backend.h": "// source-only shift\n" + source})
+ assert {name: node["id"] for name, node in classes(shifted).items()} == original_ids
+
+
+def test_req_qml008_ac02_forward_facts_do_not_replace_unique_complete_body(tmp_path):
+ source = "class Base; class Base; class Base { Q_OBJECT }; namespace Public { class Base; class Base { Q_OBJECT }; }"
+ result = analysis(tmp_path, {"backend.h": source})
+ accepted = classes(result)
+ assert set(accepted) == {"Base", "Public::Base"}
+ assert all(node["metadata"]["cpp_class"]["is_definition"] is True
+ and node["metadata"]["cpp_class"]["ambiguous"] is False for node in accepted.values())
+ qt_classes = sites(result, "class")
+ assert sum(qt_metadata(node)["is_definition"] is False for node in qt_classes) == 3
+
+
+def test_req_qml017_ac04_same_file_native_ancestry_uses_exact_class_not_basename(tmp_path):
+ declarations = ('class Base : public QWidget { Q_OBJECT }; '
+ 'namespace Public { class Base : public QObject { Q_OBJECT }; '
+ 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }')
+ result, site, links = native(tmp_path, declarations)
+ assert qt_metadata(site)["status"] == "resolved" and len(links) == 1
+ accepted = classes(result)
+ assert accepted["Base"]["id"] != accepted["Public::Base"]["id"]
+ qt_classes = {qt_metadata(node)["class_name"]: qt_metadata(node) for node in sites(result, "class")}
+ assert qt_classes["Public::Backend"]["canonical_base_names"] == ["Public::Base"]
+ assert qt_classes["Public::Base"]["class_id"] == accepted["Public::Base"]["id"]
+
+
+def test_req_qml016_ac04_header_definition_events_keep_qualified_canonical_owners(tmp_path):
+ header = '''class Base : public QObject { Q_OBJECT
+public: void run(); signals: void changed(); };
+namespace Public { class Base : public QObject { Q_OBJECT
+public: void run(); signals: void changed(); }; }
+'''
+ implementation = '#include "backend.h"\nvoid Base::run() { emit changed(); }\nvoid Public::Base::run() { emit changed(); }\n'
+ result = analysis(tmp_path, {"backend.h": header, "backend.cpp": implementation})
+ accepted = classes(result)
+ assert set(accepted) == {"Base", "Public::Base"}
+ run = [node for node in result["nodes"] if node.get("definition_file") == "backend.cpp"]
+ assert len(run) == 2 and len({node["id"] for node in run}) == 2
+ owner_links = [edge for edge in result["edges"] if edge.get("relation") == "method"
+ and edge["target"] in {node["id"] for node in run}]
+ assert {edge["source"] for edge in owner_links} == {node["id"] for node in accepted.values()}
+ emissions = sites(result, "emission")
+ assert len(emissions) == 2 and all(qt_metadata(node)["status"] == "resolved" for node in emissions)
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ restored = load_node_link_graph(payload)
+ for site in emissions:
+ md = qt_metadata(site)
+ assert (tmp_path / "backend.cpp").read_bytes()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"changed()"
+ assert md["explicit_emit"] is True
+ links = [edge for edge in payload["links"] if edge["source"] == site["id"] and edge.get("context") == "qt_signal_emit"]
+ assert len(links) == 1
+ edge = restored.edges[links[0]["source"], links[0]["target"]]
+ assert (edge["_src"], edge["_tgt"]) == (links[0]["source"], links[0]["target"])
+
+
+def test_req_qml008_ac02_conflicting_complete_bodies_remain_ambiguous(tmp_path):
+ result = analysis(tmp_path, {"backend.h": 'namespace Public { class Base {}; class Base {}; }'})
+ accepted = classes(result)
+ assert accepted["Public::Base"]["metadata"]["cpp_class"]["ambiguous"] is True
+
+
+@pytest.mark.parametrize("filename", ["backend.h", "b" * 205 + ".h"])
+def test_req_qml008_ac02_long_qualified_discriminator_survives_real_graph_transport(tmp_path, filename):
+ prefix = "N" * 330
+ source = f"namespace {prefix} {{class Base {{public: void run() {{}};}};}} namespace {prefix.lower()} {{class Base {{public: void run() {{}};}};}}"
+ result = analysis(tmp_path, {filename: source})
+ names = {prefix + "::Base", prefix.lower() + "::Base"}
+ accepted = classes(result)
+ assert set(accepted) == names and len({node["id"] for node in accepted.values()}) == 2
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ published = {base64.b64decode(data["metadata"]["cpp_class"]["qualified_name_b64"]).decode(): identity
+ for identity, data in restored.nodes(data=True) if data.get("metadata", {}).get("cpp_class")}
+ assert set(published) == names and len(set(published.values())) == 2
+ for name in names:
+ assert published[name] == accepted[name]["id"]
+ assert any(source == published[name] and data.get("relation") == "method"
+ for source, _, data in restored.edges(published[name], data=True))
+
+
+def test_req_qml008_ac02_global_class_and_member_ids_retain_existing_contract(tmp_path):
+ path = tmp_path / "backend.h"
+ path.write_text("class Base {public: void run() {}};", encoding="utf-8")
+ result = extract_cpp(path)
+ owner = classes(result)["Base"]
+ assert owner["id"] == _make_id(_file_stem(path), "Base")
+ method = next(node for node in result["nodes"] if node.get("label") == ".run()")
+ assert method["id"] == _make_id(owner["id"], "run")
+
+
+def test_req_qml008_ac02_inheritance_cannot_borrow_later_namespace_class_body(tmp_path):
+ result = analysis(tmp_path, {"backend.h": "class Base {}; namespace Public { class Derived : public Base {}; class Base {}; }"})
+ accepted = classes(result)
+ edge = next(edge for edge in result["edges"] if edge["source"] == accepted["Public::Derived"]["id"]
+ and edge["relation"] == "inherits")
+ assert edge["target"] == accepted["Base"]["id"] != accepted["Public::Base"]["id"]
+
+
+def test_req_qml008_ac02_original_bom_crlf_unicode_spans_survive_qualified_member_join(tmp_path):
+ header = '\ufeff// π原\r\nnamespace Public {\r\nclass Base : public QObject { Q_OBJECT\r\npublic: void run(); signals: void changed(); }; }\r\n'
+ implementation = '\ufeff// π原\r\n#include "backend.h"\r\nvoid Public::Base::run() { emit changed(); }\r\n'
+ result = analysis(tmp_path, {"backend.h": header, "backend.cpp": implementation})
+ owner = classes(result)["Public::Base"]
+ span = owner["metadata"]["cpp_class"]["span"]
+ assert header.encode()[span["start_byte"]:span["end_byte"]].startswith(b"class Base")
+ method = next(node for node in result["nodes"] if node.get("definition_file") == "backend.cpp")
+ assert method["definition_location"] == "L3"
+ emission = sites(result, "emission")[0]
+ md = qt_metadata(emission)
+ assert md["owner_id"] == method["id"] and md["status"] == "resolved"
+ assert implementation.encode()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"changed()"
diff --git a/tests/test_cpp_qualified_proof.py b/tests/test_cpp_qualified_proof.py
new file mode 100644
index 0000000000..b6e32c5ff1
--- /dev/null
+++ b/tests/test_cpp_qualified_proof.py
@@ -0,0 +1,144 @@
+"""REQ-QML-008: qualified joins reject corrupt or competing source authority."""
+import copy
+
+import pytest
+
+from graphify.extract import extract_cpp
+from graphify.extractors.cpp_class_proof import class_merge_allowed
+from graphify.extractors.cpp_member_identity import canonicalize_cpp_members, member_merge_allowed
+
+
+def accepted(root):
+ header = root / "backend.h"
+ implementation = root / "backend.cpp"
+ header.write_text("namespace Public { class Base { public: void run(int value); }; }", encoding="utf-8")
+ implementation.write_text('#include "backend.h"\nusing namespace Public;\nvoid Base::run(int value) {}', encoding="utf-8")
+ results = [extract_cpp(path) for path in (header, implementation)]
+ nodes = copy.deepcopy([node for result in results for node in result["nodes"]])
+ edges = copy.deepcopy([edge for result in results for edge in result["edges"]])
+ declaration = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "declaration")
+ definition = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "definition")
+ owner = next(node for node in nodes if node.get("metadata", {}).get("cpp_class"))
+ return nodes, edges, declaration, definition, owner
+
+
+def test_req_qml008_ac02_literal_using_namespace_binds_exact_prototype_and_raw_call_owner(tmp_path):
+ nodes, edges, declaration, definition, _ = accepted(tmp_path)
+ original = definition["id"]
+ raw_calls = [{"source": original, "target": "observed", "line": 3}]
+ canonicalize_cpp_members(nodes, edges, raw_calls)
+ assert definition["id"] == declaration["id"] != original
+ assert raw_calls == [{"source": declaration["id"], "target": "observed", "line": 3}]
+ assert member_merge_allowed([declaration, definition])
+ assert not any(edge.get("target") == original for edge in edges)
+
+
+@pytest.mark.parametrize("corruption", [
+ "missing_definition_parent", "foreign_definition_parent", "inferred_definition_parent",
+ "wrong_definition_line", "reversed_columns", "noncallable", "corrupt_transport",
+ "missing_class", "incomplete_class", "ambiguous_class", "corrupt_class",
+ "missing_prototype_parent", "foreign_prototype_parent", "inferred_prototype_parent",
+ "wrong_prototype_line", "ambiguous_prototype", "wrong_signature", "wrong_using",
+])
+def test_req_qml008_ac02_member_join_rejects_missing_corrupt_and_ambiguous_authority(tmp_path, corruption):
+ nodes, edges, declaration, definition, owner = accepted(tmp_path)
+ original = definition["id"]
+ member = definition["metadata"]["cpp_member"]
+ if corruption.endswith("definition_parent"):
+ links = [edge for edge in edges if edge["target"] == original and edge["relation"] == "contains"]
+ assert links
+ if corruption.startswith("missing"):
+ edges[:] = [edge for edge in edges if edge not in links]
+ else:
+ for edge in links:
+ edge["source_file" if corruption.startswith("foreign") else "confidence"] = (
+ "foreign.cpp" if corruption.startswith("foreign") else "INFERRED")
+ elif corruption.endswith("prototype_parent"):
+ links = [edge for edge in edges if edge["target"] == declaration["id"] and edge["relation"] == "method"]
+ assert links
+ if corruption.startswith("missing"):
+ edges[:] = [edge for edge in edges if edge not in links]
+ else:
+ for edge in links:
+ edge["source_file" if corruption.startswith("foreign") else "confidence"] = (
+ "foreign.h" if corruption.startswith("foreign") else "INFERRED")
+ elif corruption in {"wrong_definition_line", "wrong_prototype_line"}:
+ (definition if corruption == "wrong_definition_line" else declaration)["source_location"] = "L99"
+ elif corruption == "reversed_columns":
+ member["span"].update(start_column=100, end_column=0)
+ elif corruption == "noncallable":
+ definition["_callable"] = False
+ elif corruption == "corrupt_transport":
+ member["owner_b64"] = "invalid!"
+ elif corruption == "wrong_signature":
+ member["signature"] = "0" * 64
+ elif corruption == "wrong_using":
+ member["using_b64"] = []
+ elif corruption == "ambiguous_prototype":
+ declaration["metadata"]["cpp_member"]["ambiguous"] = True
+ elif corruption == "missing_class":
+ owner["metadata"].pop("cpp_class")
+ elif corruption == "corrupt_class":
+ owner["metadata"]["cpp_class"]["qualified_name_b64"] = "invalid!"
+ else:
+ owner["metadata"]["cpp_class"]["is_definition" if corruption == "incomplete_class" else "ambiguous"] = (
+ False if corruption == "incomplete_class" else True)
+ before_calls = [{"source": original}]
+ canonicalize_cpp_members(nodes, edges, before_calls)
+ assert definition["id"] == original
+ assert before_calls == [{"source": original}]
+ assert not member.get("bound_owner_b64")
+
+
+@pytest.mark.parametrize("corruption", ["qualified", "scope", "span", "missing", "ambiguous"])
+def test_req_qml008_ac02_class_merge_never_repairs_invalid_qualified_proof_by_label(tmp_path, corruption):
+ _, _, _, _, owner = accepted(tmp_path)
+ forward = copy.deepcopy(owner)
+ forward["source_file"] = "backend.cpp"
+ forward["metadata"]["cpp_class"]["is_definition"] = False
+ fact = forward["metadata"]["cpp_class"]
+ if corruption == "qualified":
+ fact["qualified_name_b64"] = "QmFzZQ==" # Base, not Public::Base.
+ elif corruption == "scope":
+ fact["scope_b64"] = ""
+ elif corruption == "span":
+ fact["span"]["start_row"] = True
+ elif corruption == "missing":
+ forward["metadata"].pop("cpp_class")
+ else:
+ fact["ambiguous"] = True
+ assert not class_merge_allowed([owner, forward])
+
+
+@pytest.mark.parametrize("declaration,definition,joins", [
+ ("const &", "const &", True), ("const", "", False),
+ ("volatile", "const", False), ("&", "&&", False),
+])
+def test_req_qml008_ac02_qualified_member_signature_preserves_cv_and_reference_overloads(tmp_path, declaration, definition, joins):
+ header = tmp_path / "backend.h"
+ implementation = tmp_path / "backend.cpp"
+ header.write_text(f"namespace Public {{class Base {{public: void run() {declaration};}};}}", encoding="utf-8")
+ implementation.write_text(f"void Public::Base::run() {definition} {{}}", encoding="utf-8")
+ results = [extract_cpp(path) for path in (header, implementation)]
+ nodes = [node for result in results for node in result["nodes"]]
+ edges = [edge for result in results for edge in result["edges"]]
+ prototype = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "declaration")
+ method = next(node for node in nodes if node.get("metadata", {}).get("cpp_member", {}).get("role") == "definition")
+ canonicalize_cpp_members(nodes, edges)
+ assert bool(method["metadata"]["cpp_member"].get("bound_owner_b64")) is joins
+ assert member_merge_allowed([prototype, method]) is joins
+
+
+@pytest.mark.parametrize("extra", [0, 49])
+def test_req_qml008_ac02_using_namespace_bound_cannot_discard_competing_owner(tmp_path, extra):
+ from tests.qt_analysis_helpers import analysis
+
+ header = "namespace N00 {class Base {public: void run();};} namespace ZZ {class Base {public: void run();};}"
+ filler = "".join(f"namespace N{index:02} {{}}" for index in range(1, extra + 1))
+ usings = "\n".join(f"using namespace N{index:02};" for index in range(extra + 1))
+ implementation = '#include "backend.h"\n' + usings + "\nusing namespace ZZ;\nvoid Base::run() {}"
+ result = analysis(tmp_path, {"backend.h": header + filler, "backend.cpp": implementation})
+ definitions = [node for node in result["nodes"] if node.get("metadata", {}).get("cpp_member", {}).get("role") == "definition"]
+ assert len(definitions) == 1 and definitions[0]["source_file"] == "backend.cpp"
+ assert not definitions[0]["metadata"]["cpp_member"].get("bound_owner_b64")
+ assert not any(edge["target"] == definitions[0]["id"] and edge["relation"] == "method" for edge in result["edges"])
diff --git a/tests/test_cpp_qualified_updates.py b/tests/test_cpp_qualified_updates.py
new file mode 100644
index 0000000000..d345553738
--- /dev/null
+++ b/tests/test_cpp_qualified_updates.py
@@ -0,0 +1,74 @@
+"""REQ-QML-008/016/017: changed qualified ownership survives real updates."""
+import base64
+
+import pytest
+
+from graphify.affected import affected_nodes, load_graph
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+from tests.test_qt_semantic_correction_updates import products
+
+
+HEADER = '''class Base : public QWidget { Q_OBJECT };
+namespace Public { class Base : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service)
+public: Q_INVOKABLE int fetch(); void run(); signals: void changed(); }; }
+'''
+IMPLEMENTATION = '#include "backend.h"\nint Public::Base::fetch() { return 2; }\nvoid Public::Base::run() { emit changed(); }\n'
+
+
+def fixture(root):
+ sources = {
+ "backend.h": HEADER, "backend.cpp": IMPLEMENTATION,
+ "Main.qml": 'import Public.Tools 1.0\nService { function refresh() { fetch() } }',
+ "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h backend.cpp)',
+ "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n',
+ }
+ for name, source in sources.items():
+ (root / name).write_text(source, encoding="utf-8", newline="")
+
+
+def owners(graph):
+ return {base64.b64decode(data["metadata"]["cpp_class"]["qualified_name_b64"]).decode(): identity
+ for identity, data in graph.nodes(data=True) if data.get("metadata", {}).get("cpp_class")}
+
+
+def evidence(graph):
+ emissions = [(identity, qt_metadata(data)) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") == "emission"]
+ assert len(emissions) == 1 and emissions[0][1]["status"] == "resolved"
+ identity, metadata = emissions[0]
+ assert metadata["owner_id"] in graph
+ assert any(data.get("_src", source) == identity and data.get("context") == "qt_signal_emit"
+ for source, _, data in graph.edges(data=True))
+ calls = [(source, target, data) for source, target, data in graph.edges(data=True)
+ if data.get("relation") == "calls" and qt_metadata(data).get("native_endpoint")]
+ assert calls
+ return identity, metadata
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_qualified_owner_edits_match_cold_warm_updates_and_consumers(tmp_path, monkeypatch, operation):
+ fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ before = owners(initial)
+ assert set(before) == {"Base", "Public::Base"}
+ evidence(initial)
+ assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold"))
+ (tmp_path / "backend.h").write_text(HEADER.replace("namespace Public", "namespace PUBLIC"), encoding="utf-8")
+ (tmp_path / "backend.cpp").write_text(IMPLEMENTATION.replace("Public::", "PUBLIC::"), encoding="utf-8")
+ updated = run(tmp_path, monkeypatch, operation, [tmp_path / "backend.h", tmp_path / "backend.cpp"])
+ after = owners(updated)
+ assert set(after) == {"Base", "PUBLIC::Base"}
+ assert after["Base"] == before["Base"] and after["PUBLIC::Base"] != before["Public::Base"]
+ assert before["Public::Base"] not in updated
+ identity, metadata = evidence(updated)
+ assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".changed"))
+ assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".changed"))
+ assert unrelated(initial) == unrelated(updated)
+ consumer = load_graph(tmp_path / "graphify-out/graph.json")
+ assert identity in {hit.node_id for hit in affected_nodes(consumer, metadata["target_id"], relations=["uses"], depth=1)}
+ unchanged = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(updated)
+ assert products(tmp_path) == unchanged
diff --git a/tests/test_cpp_reference_return_updates.py b/tests/test_cpp_reference_return_updates.py
new file mode 100644
index 0000000000..bd86db27c6
--- /dev/null
+++ b/tests/test_cpp_reference_return_updates.py
@@ -0,0 +1,140 @@
+"""REQ-QML-018-AC02/AC03 reference factories retain production refresh and recovery."""
+from __future__ import annotations
+
+import os
+import stat
+
+import pytest
+
+import graphify.watch as watch
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_adoption_fixture import NATIVE, sources
+from tests.test_qt_event_incremental_consumers import run
+from tests.test_qt_final_incremental_parity import clean, normalized, unrelated
+from tests.test_qt_product_publication import reject, snapshot
+
+
+REFERENCE = NATIVE.replace("Backend *makeBackend()", "Backend &makeBackend()")
+
+
+def fixture(root):
+ """Only native signature source changes; engine scope and QML clients stay fixed."""
+ inputs = sources(root, native=REFERENCE)
+ inputs["keep.py"] = "def helper(): return 7\n\ndef retained(): return helper()\n" + "\n".join(
+ f"def keep_{index}(): return {index}" for index in range(30))
+ for name, content in inputs.items():
+ (root / name).write_bytes(content.encode())
+ return root / "native.h"
+
+
+def callable_and_provider(graph, *, accepted=True):
+ """Canonical factory remains a source callable independently of supported provider shape."""
+ members = [(identity, qt_metadata(data)) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") == "member" and qt_metadata(data).get("raw_name") == "makeBackend"]
+ assert len(members) == 1 and members[0][1]["generic_target_id"] in graph
+ target = members[0][1]["generic_target_id"]
+ assert graph.nodes[target]["_callable"] and graph.nodes[target]["label"] == ".makeBackend()"
+ bindings = {identity for identity, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "context_binding"}
+ accesses = {identity for identity, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "context_access"}
+ subscriptions = {identity for identity, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "context_subscription"}
+ if accepted:
+ assert len(bindings) == 1 and accesses and len(subscriptions) == 2
+ assert all(qt_metadata(graph.nodes[identity])["status"] == "resolved" for identity in accesses | subscriptions)
+ assert all(qt_metadata(graph.nodes[identity])["provider_class_name"] == "Backend" for identity in bindings)
+ else:
+ assert not bindings and not accesses and not subscriptions
+ return target, bindings | accesses | subscriptions
+
+
+def parity(graph, root, name):
+ """Entire durable source facts and typed edge metadata match cold and reused caches."""
+ cache = root / (".clean-" + name)
+ assert normalized(graph) == normalized(clean(root, cache)) == normalized(clean(root, cache))
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac02_ac03_reference_factory_edits_keep_identity_and_drop_stale_provider_edges(tmp_path, monkeypatch, operation):
+ """C++-only pointer/ref edits retain generic identity; unavailable return removes exposure."""
+ header = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ target, original_sites = callable_and_provider(initial)
+ parity(initial, tmp_path, "initial")
+ untouched = unrelated(initial)
+ header.write_bytes(NATIVE.encode())
+ pointer = run(tmp_path, monkeypatch, operation, [header])
+ assert callable_and_provider(pointer)[0] == target
+ parity(pointer, tmp_path, "pointer")
+ header.write_bytes(REFERENCE.replace("Backend &makeBackend()", "Unknown &makeBackend()").encode())
+ removed = run(tmp_path, monkeypatch, operation, [header])
+ assert callable_and_provider(removed, accepted=False)[0] == target
+ assert all(identity not in removed for identity in original_sites)
+ assert not any(data.get("context") in {"qt_context_member", "qt_context_subscription"}
+ for _, _, data in removed.edges(data=True))
+ parity(removed, tmp_path, "unavailable")
+ header.write_bytes(REFERENCE.encode())
+ restored = run(tmp_path, monkeypatch, operation, [header])
+ assert normalized(restored) == normalized(initial)
+ assert callable_and_provider(restored)[0] == target
+ assert unrelated(restored) == untouched
+ current = snapshot(tmp_path / "graphify-out")
+ assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(restored)
+ assert snapshot(tmp_path / "graphify-out") == current
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("failure", ["syntax", "publication"])
+def test_req_qml018_ac02_ac03_reference_admission_failure_preserves_and_repairs(tmp_path, monkeypatch, operation, failure):
+ """Malformed reference source or real late publication fault cannot replace prior acceptance."""
+ header = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ callable_and_provider(initial)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ if failure == "syntax":
+ header.write_bytes(REFERENCE.replace("Backend &makeBackend()", "Backend &makeBackend( {").encode())
+ reject(tmp_path, monkeypatch, operation, header)
+ else:
+ header.write_bytes(NATIVE.encode())
+ replace = watch.os_replace_with_fallback
+
+ def fail_late(source, destination):
+ if destination.name == "manifest.json":
+ raise OSError("public fixture reference publication failure")
+ return replace(source, destination)
+
+ with monkeypatch.context() as fault:
+ fault.setattr(watch, "os_replace_with_fallback", fail_late)
+ reject(tmp_path, fault, operation, header)
+ assert snapshot(output) == before
+ header.write_bytes(REFERENCE.encode())
+ repaired = run(tmp_path, monkeypatch, operation, [header])
+ assert normalized(repaired) == normalized(initial)
+ parity(repaired, tmp_path, "repaired")
+ accepted = snapshot(output)
+ assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(repaired)
+ assert snapshot(output) == accepted
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary")
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac02_ac03_readonly_reference_edit_retains_products_and_retries(tmp_path, monkeypatch, operation):
+ """A genuine OS refusal retains prior graph and sidecars until repaired publication."""
+ header = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ target, _ = callable_and_provider(initial)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ header.write_bytes(NATIVE.encode())
+ destination = output / "manifest.json"
+ destination.chmod(stat.S_IREAD)
+ try:
+ reject(tmp_path, monkeypatch, operation, header)
+ assert snapshot(output) == before
+ finally:
+ destination.chmod(stat.S_IWRITE)
+ repaired = run(tmp_path, monkeypatch, operation, [header])
+ assert callable_and_provider(repaired)[0] == target
+ parity(repaired, tmp_path, "readonly-retry")
diff --git a/tests/test_cpp_reference_returns.py b/tests/test_cpp_reference_returns.py
new file mode 100644
index 0000000000..6c2ba9e087
--- /dev/null
+++ b/tests/test_cpp_reference_returns.py
@@ -0,0 +1,128 @@
+"""REQ-QML-018-AC02/AC03 canonical generic reference-return declarations."""
+import json
+
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract_cpp
+from graphify.extractors.engine import _get_cpp_func_name
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_syntax import read_cpp, walk
+from graphify.paths import load_node_link_graph
+from graphify.serve import _query_graph_text
+from tests.qt_adoption_fixture import NATIVE, sources
+from tests.qt_analysis_helpers import analysis, sites
+
+
+@pytest.mark.parametrize("result_type", ["Backend &", "Backend &&", "Backend", "Backend *"])
+def test_req_qml018_ac02_ac03_reference_return_has_canonical_callable(tmp_path, result_type):
+ """Generic producer owns the declaration; the provider lends its declared API."""
+ native = NATIVE.replace("Backend *makeBackend()", result_type + " makeBackend()")
+ result = analysis(tmp_path, sources(tmp_path, native=native))
+ member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend")
+ target = qt_metadata(member)["generic_target_id"]
+ assert target
+ direct = extract_cpp(tmp_path / "native.h")
+ direct_members = [node for node in direct["nodes"] if node.get("_callable") and node["label"] == ".makeBackend()"]
+ assert len(direct_members) == 1
+ by_id = {node["id"]: node for node in result["nodes"]}
+ assert by_id[target]["label"] == ".makeBackend()" and by_id[target]["_callable"]
+ if result_type in {"Backend &", "Backend *"}:
+ assert len(sites(result, "context_binding")) == 1
+ assert sites(result, "context_access") and len(sites(result, "context_subscription")) == 2
+ else:
+ # Generic callable admission does not widen the one-object provider
+ # profile to value temporaries or unsupported rvalue references.
+ assert not sites(result, "context_binding")
+
+
+@pytest.mark.parametrize("result_type", ["Backend &", "Backend &&", "const Backend &", "Backend *", "Backend"])
+def test_req_qml018_ac02_qualified_reference_declaration_and_definition_keep_one_callable(tmp_path, result_type):
+ """Wrapping the actual function declarator preserves full names and canonical joins."""
+ header = "namespace Public { class Backend {}; class Factory { public: " + result_type + " create(); }; }\n"
+ body = '#include "native.h"\n' + result_type.replace("Backend", "Public::Backend") + " Public::Factory::create() { throw 7; }\n"
+ result = analysis(tmp_path, {"native.h": header, "native.cpp": body})
+ candidates = [node for node in result["nodes"] if node.get("_callable") and node.get("label") == ".create()"]
+ assert len(candidates) == 1
+ candidate = candidates[0]
+ assert candidate["source_file"] == "native.h" and candidate["definition_file"] == "native.cpp"
+ for name in ["native.h", "native.cpp"]:
+ unit = read_cpp(tmp_path / name, tmp_path)
+ declarators = [node for node in walk(unit.root) if node.type == "function_declarator"]
+ assert len(declarators) == 1
+ outer = declarators[0].parent
+ value = _get_cpp_func_name(outer, unit.source)
+ assert value == ("create" if name.endswith(".h") else "Public::Factory::create")
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml018_ac02_ac03_reference_provider_spans_and_consumers_survive_reload(tmp_path, directed):
+ """Original BOM/CRLF/Unicode bytes and typed-provider evidence reach durable consumers."""
+ native = ("\ufeff// café 雪\n" + NATIVE.replace("Backend *makeBackend()", "Backend &makeBackend()")).replace("\n", "\r\n")
+ result = analysis(tmp_path, sources(tmp_path, native=native))
+ member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend")
+ metadata = qt_metadata(member)
+ span = metadata["span"]
+ assert (tmp_path / "native.h").read_bytes()[span["start_byte"]:span["end_byte"]] == b"Backend &makeBackend() { throw 7; }"
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ assert metadata["generic_target_id"] in reloaded
+ assert qt_metadata(reloaded.nodes[member["id"]]) == metadata
+ accesses = sites(result, "context_access")
+ assert accesses and len(sites(result, "context_subscription")) == 2
+ for access in accesses:
+ md = qt_metadata(reloaded.nodes[access["id"]])
+ assert md["status"] == "resolved" and md["endpoint_proof"]["canonical_target_id"] in reloaded
+ assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(access["id"], data=True)
+ if data.get("context") == "qt_context_subscription")
+ assert "Main.qml" in _query_graph_text(reloaded, "backend service refresh", token_budget=4000)
+ access = next(node for node in accesses if qt_metadata(node)["endpoint_proof"]["kind"] == "function")
+ terminal = qt_metadata(access)["endpoint_proof"]["canonical_target_id"]
+ if directed:
+ assert reloaded.has_edge(access["id"], terminal)
+ assert any(hit.node_id == access["id"] for hit in affected_nodes(reloaded, terminal, depth=3))
+ else:
+ edge = reloaded.edges[access["id"], terminal]
+ assert (edge["_src"], edge["_tgt"]) == (access["id"], terminal)
+ # INC-QML-30 separately owns affected's undirected logical direction;
+ # the persisted producer/consumer edge here retains its exact endpoints.
+
+
+def test_req_qml018_ac02_reference_fallback_requires_one_function_child():
+ """Malformed or competing grammar children cannot supply an arbitrary callable name."""
+ class Syntax:
+ def __init__(self, kind, children=(), name=None):
+ self.type, self.children, self.name = kind, children, name
+
+ def child_by_field_name(self, field):
+ return self.name if field == "declarator" else None
+
+ # The real grammar defect is covered above. This isolated malformed-node
+ # guard checks fallback shape without inventing extraction behavior in a fake.
+ children = [Syntax("function_declarator"), Syntax("function_declarator")]
+ assert _get_cpp_func_name(Syntax("reference_declarator", children), b"") is None
+ assert _get_cpp_func_name(Syntax("reference_declarator", [Syntax("type_identifier")]), b"") is None
+
+
+def test_req_qml018_ac03_unproven_factory_overloads_stay_unavailable(tmp_path):
+ """Reference admission does not authorize ambiguous callable selection."""
+ native = NATIVE.replace("Backend *makeBackend() { throw 7; }", "Backend &makeBackend(); Service &makeBackend();")
+ result = analysis(tmp_path, sources(tmp_path, native=native))
+ assert not sites(result, "context_binding")
+ assert not sites(result, "context_access")
+
+
+def test_req_qml018_ac03_const_reference_retains_original_declared_api_shape(tmp_path):
+ """Static declared surface retains CV without proving a runtime QObject conversion."""
+ result = analysis(tmp_path, sources(tmp_path, native=NATIVE.replace("Backend *makeBackend()", "const Backend &makeBackend()")))
+ member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend")
+ metadata = qt_metadata(member)
+ assert metadata["generic_target_id"]
+ assert metadata["api_type_spelling"] == "const Backend &"
+ assert metadata["api_type_status"] == "resolved"
+ assert len(sites(result, "context_binding")) == 1
+ assert sites(result, "context_access") and len(sites(result, "context_subscription")) == 2
diff --git a/tests/test_extract.py b/tests/test_extract.py
index ef3b10b21d..1117790998 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -468,7 +468,7 @@ def test_collect_files_from_dir():
from graphify.extract import _DISPATCH
files = collect_files(FIXTURES)
supported = set(_DISPATCH.keys())
- assert all(f.suffix in supported for f in files)
+ assert all(f.suffix in supported or f.name in {"qmldir", "CMakeLists.txt"} for f in files)
assert len(files) > 0
@@ -545,7 +545,11 @@ def _legacy_collect_files(target, *, root=None):
def test_collect_files_parity_with_legacy_on_fixtures():
- assert collect_files(FIXTURES) == _legacy_collect_files(FIXTURES)
+ files = collect_files(FIXTURES)
+ # The extension-only legacy oracle predates exact-name QML metadata.
+ assert [path for path in files if path.name not in {"qmldir", "CMakeLists.txt"}] == _legacy_collect_files(FIXTURES)
+ assert [path for path in files if path.name == "qmldir"] == [
+ FIXTURES / "qml" / "parser_probe" / "qmldir"]
def test_collect_files_parity_with_legacy_synthetic(tmp_path):
@@ -5416,8 +5420,11 @@ def test_python_external_calls_survive_real_incremental_context(tmp_path):
(tmp_path / "ext_b.py").write_text("import requests as rq\n\ndef fetch_b():\n return rq.post('/b')\n")
(tmp_path / "helper.py").write_text("def other():\n return 1\n")
(tmp_path / "local_caller.py").write_text("import helper\n\ndef fetch_local():\n return helper.get()\n")
+ # The native subprocess needs Windows home/system fields; HOME alone is
+ # POSIX-only. Inherit sandboxed paths while keeping provider state excluded.
env = {k: v for k, v in os.environ.items()
- if k in {"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "PYTHONDONTWRITEBYTECODE"}}
+ if k in {"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "PYTHONDONTWRITEBYTECODE",
+ "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SYSTEMROOT", "TEMP", "TMP"}}
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
def run():
diff --git a/tests/test_filesystem_profiles.py b/tests/test_filesystem_profiles.py
new file mode 100644
index 0000000000..009bface16
--- /dev/null
+++ b/tests/test_filesystem_profiles.py
@@ -0,0 +1,203 @@
+"""Portable admission/rebuild proof with narrow OS failure seams.
+
+Real FIFO, socket, symlink and removed-CWD fixtures remain in their original
+modules. These tests run on Windows too, protecting the production handling of
+stat results and CWD lookup failures without claiming native POSIX object proof.
+"""
+from __future__ import annotations
+
+import builtins
+import errno
+import io
+import json
+import os
+import stat
+from pathlib import Path
+
+import pytest
+
+from graphify.detect import _is_regular_file, detect
+from graphify.watch import _rebuild_code
+
+
+@pytest.mark.parametrize(
+ "mode",
+ [stat.S_IFIFO, stat.S_IFSOCK, stat.S_IFCHR, stat.S_IFBLK, stat.S_IFDIR],
+ ids=["fifo", "socket", "character-device", "block-device", "directory"],
+)
+def test_req_core_002_ac01_detect_rejects_nonregular_stat_modes(tmp_path, monkeypatch, mode):
+ """Admission rejects a special-file stat mode before any source reader runs."""
+ candidate = tmp_path / "unsafe.py"
+ candidate.write_text("def unsafe(): pass\n", encoding="utf-8")
+ safe = tmp_path / "safe.py"
+ safe.write_text("def safe(): pass\n", encoding="utf-8")
+ real_stat = os.stat
+ observed = []
+ reads = []
+
+ # Substitute only this source's native stat result. Discovery, admission,
+ # classification and reporting still use the actual production functions.
+ def special_stat(path, *args, **kwargs):
+ result = real_stat(path, *args, **kwargs)
+ if Path(path) == candidate:
+ observed.append(path)
+ fields = list(result)
+ fields[0] = mode | stat.S_IRUSR
+ return os.stat_result(fields)
+ return result
+
+ monkeypatch.setattr(os, "stat", special_stat)
+ # Watch both builtin and pathlib readers: a caught reader error must not
+ # conceal an attempted read of the source classified as nonregular.
+ def guarded_reader(reader):
+ def read(path, *args, **kwargs):
+ if not isinstance(path, int) and Path(path) == candidate:
+ reads.append(path)
+ raise AssertionError("nonregular source reached a reader")
+ return reader(path, *args, **kwargs)
+ return read
+
+ monkeypatch.setattr(builtins, "open", guarded_reader(builtins.open))
+ monkeypatch.setattr(io, "open", guarded_reader(io.open))
+ assert _is_regular_file(candidate) is False
+ result = detect(tmp_path)
+ assert observed
+ assert str(candidate) not in result["files"]["code"]
+ assert str(safe) in result["files"]["code"]
+ assert str(candidate) + " [not a regular file]" in result["skipped_sensitive"]
+ assert reads == []
+
+
+@pytest.mark.parametrize("error_code", [errno.EACCES, errno.ENOENT, errno.ELOOP, errno.EIO])
+def test_req_core_002_ac01_stat_failures_are_unreadable(tmp_path, monkeypatch, error_code):
+ """Access failure, removal race, link loop and I/O failure reject the source."""
+ candidate = tmp_path / "unreadable.py"
+ candidate.write_text("def unreadable(): pass\n", encoding="utf-8")
+ real_stat = os.stat
+ calls = []
+
+ # The source exists; only its owning stat boundary fails. Any other path,
+ # including pytest's own artifacts, keeps the native filesystem behavior.
+ def failed_stat(path, *args, **kwargs):
+ if Path(path) == candidate:
+ calls.append(path)
+ raise OSError(error_code, os.strerror(error_code), str(candidate))
+ return real_stat(path, *args, **kwargs)
+
+ monkeypatch.setattr(os, "stat", failed_stat)
+ assert _is_regular_file(candidate) is False
+ assert calls == [candidate]
+
+
+def _fail_cwd_lookup(monkeypatch, unavailable):
+ """Fail only cwd lookup in one real directory, preserving recovery chdir."""
+ real_cwd = Path.cwd
+
+ def lookup(cls):
+ cwd = real_cwd()
+ if cwd == unavailable:
+ raise FileNotFoundError(errno.ENOENT, "current working directory was removed")
+ return cwd
+
+ monkeypatch.setattr(Path, "cwd", classmethod(lookup))
+
+
+@pytest.mark.parametrize("root_kind", ["unset", "missing", "chdir-denied"])
+def test_req_core_002_ac02_unavailable_cwd_rejects_before_artifacts(
+ tmp_path, monkeypatch, capsys, root_kind
+):
+ """A failed CWD lookup retains prior graph bytes and creates no queue or lock."""
+ unavailable = tmp_path / "unavailable"
+ unavailable.mkdir()
+ out = unavailable / "graphify-out"
+ out.mkdir()
+ graph = out / "graph.json"
+ prior = b'{"nodes": [], "retained": true}\n'
+ graph.write_bytes(prior)
+ if root_kind == "missing":
+ monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(tmp_path / "missing"))
+ elif root_kind == "chdir-denied":
+ denied = tmp_path / "denied"
+ denied.mkdir()
+ monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(denied))
+ real_chdir = os.chdir
+
+ def failed_chdir(path):
+ if Path(path) == denied:
+ raise PermissionError(errno.EACCES, "repository root cannot be entered")
+ real_chdir(path)
+
+ monkeypatch.setattr(os, "chdir", failed_chdir)
+ else:
+ monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False)
+ monkeypatch.chdir(unavailable)
+ _fail_cwd_lookup(monkeypatch, unavailable)
+
+ assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False
+ assert graph.read_bytes() == prior
+ assert {path.name for path in out.iterdir()} == {"graph.json"}
+ assert "current working directory no longer exists" in capsys.readouterr().out
+
+
+def test_req_core_002_ac02_repo_root_recovers_cwd_and_publishes_graph(tmp_path, monkeypatch):
+ """Real chdir recovery reaches extraction, persistence and lock/queue cleanup."""
+ unavailable = tmp_path / "unavailable"
+ unavailable.mkdir()
+ corpus = tmp_path / "corpus"
+ corpus.mkdir()
+ (corpus / "lib.py").write_text("def recovered(): pass\n", encoding="utf-8")
+ monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(corpus))
+ monkeypatch.chdir(unavailable)
+ _fail_cwd_lookup(monkeypatch, unavailable)
+ with pytest.raises(FileNotFoundError):
+ Path.cwd()
+
+ assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")], no_cluster=True) is True
+ assert Path.cwd() == corpus
+ out = corpus / "graphify-out"
+ persisted = json.loads((out / "graph.json").read_text(encoding="utf-8"))
+ assert any(node["label"] == "recovered()" for node in persisted["nodes"])
+ assert not (out / ".pending_changes").exists()
+ assert not (out / ".rebuild.lock").exists()
+ assert not (unavailable / "graphify-out").exists()
+
+
+@pytest.mark.parametrize(
+ ("root_kind", "reason"),
+ [
+ ("unset", "GRAPHIFY_REPO_ROOT is not set"),
+ ("missing", "GRAPHIFY_REPO_ROOT does not name an available directory"),
+ ("chdir-denied", "GRAPHIFY_REPO_ROOT could not be entered"),
+ ],
+)
+def test_req_core_002_ac03_unavailable_cwd_reports_root_reason(
+ tmp_path, monkeypatch, capsys, root_kind, reason
+):
+ """Failure diagnostics identify root setup or entry failure without path leaks."""
+ unavailable = tmp_path / "unavailable"
+ unavailable.mkdir()
+ supplied_root = tmp_path / "private-repository-root"
+ if root_kind == "unset":
+ monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False)
+ else:
+ monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(supplied_root))
+ if root_kind == "chdir-denied":
+ supplied_root.mkdir()
+ real_chdir = os.chdir
+
+ def denied_chdir(path):
+ if Path(path) == supplied_root:
+ raise PermissionError(errno.EACCES, "private failure detail", str(supplied_root))
+ real_chdir(path)
+
+ monkeypatch.setattr(os, "chdir", denied_chdir)
+ monkeypatch.chdir(unavailable)
+ _fail_cwd_lookup(monkeypatch, unavailable)
+
+ assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False
+ output = capsys.readouterr().out
+ assert "current working directory no longer exists" in output
+ assert reason in output
+ assert str(supplied_root) not in output
+ assert "private failure detail" not in output
+ assert not (unavailable / "graphify-out").exists()
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
index a4f04bccef..09cb951ff3 100644
--- a/tests/test_hooks.py
+++ b/tests/test_hooks.py
@@ -8,6 +8,7 @@
from pathlib import Path
import pytest
from graphify.hooks import install, uninstall, status, _hooks_dir, _HOOK_MARKER, _CHECKOUT_MARKER
+from tests.shell_portability import resolve_shell_path, select_shell_executable
def _make_git_repo(tmp_path: Path) -> Path:
@@ -717,7 +718,7 @@ def _detect_run(tmp_path, home, stub_bin, env_extra=None):
env.update(env_extra)
env["PATH"] = str(stub_bin) + os.pathsep + env["PATH"]
return subprocess.run(
- ["sh", script.name], capture_output=True, text=True,
+ [select_shell_executable("sh"), script.name], capture_output=True, text=True,
cwd=str(tmp_path), env=env,
)
@@ -769,8 +770,12 @@ def test_uv_tool_env_rescues_hook_when_pin_and_launcher_fail(tmp_path):
mine = _tool_venv(home, "graphifyy", "bin/python", ok=True)
res = _detect_run(tmp_path, home, stub_bin)
assert res.returncode == 0, res.stderr
- assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr
- assert f"RESOLVED={other}" not in res.stdout
+ # MSYS reports its mount spelling; compare the complete native file identity
+ # and retain rejection of the earlier same-basename sibling tool environment.
+ assert res.stdout.startswith("RESOLVED="), res.stdout + res.stderr
+ resolved = resolve_shell_path(res.stdout.removeprefix("RESOLVED=").strip())
+ assert resolved == mine.resolve()
+ assert resolved != other.resolve()
assert "could not locate" not in res.stderr
@@ -823,7 +828,12 @@ def test_shebang_parse_requires_leading_hash_bang(tmp_path):
launcher.chmod(0o755)
res = _detect_run(tmp_path, home, stub_bin)
assert res.returncode == 0, res.stderr
- assert f"RESOLVED={mine}" in res.stdout, res.stdout + res.stderr
+ # The successful fallback must be this exact tool env, never the launcher
+ # decoy, even when the shell maps its full path through an MSYS mount.
+ assert res.stdout.startswith("RESOLVED="), res.stdout + res.stderr
+ resolved = resolve_shell_path(res.stdout.removeprefix("RESOLVED=").strip())
+ assert resolved == mine.resolve()
+ assert resolved != decoy.resolve()
assert "fakepy" not in res.stdout
@@ -879,10 +889,10 @@ def _shell_verdict(pattern: str, candidate: str, tmp_path) -> str:
encoding="utf-8",
newline="\n",
)
- # Invoke by bare filename from cwd: a Windows absolute path in argv would
- # hit the same backslash mangling the script contents just avoided.
+ # Keep the script basename in argv to preserve Windows payload bytes;
+ # launch the admitted absolute shell identity to avoid native shadowing.
result = subprocess.run(
- ["bash", script.name],
+ [select_shell_executable("bash"), script.name],
capture_output=True, text=True, cwd=str(tmp_path),
)
# Fail loudly on a malformed case snippet instead of returning "" and
@@ -974,7 +984,7 @@ def test_shell_verdict_delivers_the_payload_bash_unmodified(payload, tmp_path):
encoding="utf-8", newline="\n",
)
result = subprocess.run(
- ["bash", script.name], capture_output=True, text=True, cwd=str(tmp_path),
+ [select_shell_executable("bash"), script.name], capture_output=True, text=True, cwd=str(tmp_path),
)
assert result.returncode == 0, result.stderr
assert result.stdout == payload, (
@@ -1017,7 +1027,7 @@ def test_checkout_hook_skips_same_head_noop_at_runtime():
def run(prev, new, flag):
# sh -c CMD name arg1 arg2 arg3 -> $0=name $1=prev $2=new $3=flag
- return subprocess.run(["sh", "-c", prefix, "hook", prev, new, flag],
+ return subprocess.run([select_shell_executable("sh"), "-c", prefix, "hook", prev, new, flag],
capture_output=True, text=True)
# branch switch (flag=1), SAME head -> short-circuit, sentinel not reached
@@ -1067,9 +1077,9 @@ def _git(*args, cwd):
_git("worktree", "add", "-q", str(linked), "-b", "feature", cwd=primary)
snippet = _worktree_guard_snippet()
- r_primary = subprocess.run(["sh", "-c", snippet], cwd=primary,
+ r_primary = subprocess.run([select_shell_executable("sh"), "-c", snippet], cwd=primary,
capture_output=True, text=True)
- r_linked = subprocess.run(["sh", "-c", snippet], cwd=linked,
+ r_linked = subprocess.run([select_shell_executable("sh"), "-c", snippet], cwd=linked,
capture_output=True, text=True)
assert "RAN" in r_primary.stdout, "guard wrongly skipped the primary checkout"
assert "RAN" not in r_linked.stdout, "guard failed to skip the linked worktree"
@@ -1307,7 +1317,7 @@ def test_baked_viz_limit_yields_to_an_explicit_per_run_override(tmp_path):
# prove the shell semantics: an explicit env value survives the export line
line = 'export GRAPHIFY_VIZ_NODE_LIMIT="${GRAPHIFY_VIZ_NODE_LIMIT:-100}"'
out = subprocess.run(
- ["sh", "-c", f'GRAPHIFY_VIZ_NODE_LIMIT=7; {line}; echo "$GRAPHIFY_VIZ_NODE_LIMIT"'],
+ [select_shell_executable("sh"), "-c", f'GRAPHIFY_VIZ_NODE_LIMIT=7; {line}; echo "$GRAPHIFY_VIZ_NODE_LIMIT"'],
capture_output=True, text=True, check=True,
)
assert out.stdout.strip() == "7"
@@ -1506,7 +1516,7 @@ def _emitted_hook_run(repo: Path, script: str, args: list[str], env_extra: dict[
env.pop(key, None)
env.update(env_extra)
return subprocess.run(
- ["sh", str(hook), *args], capture_output=True, text=True, cwd=str(repo), env=env,
+ [select_shell_executable("sh"), str(hook), *args], capture_output=True, text=True, cwd=str(repo), env=env,
)
diff --git a/tests/test_install.py b/tests/test_install.py
index 49a3bba32f..e52d4d8947 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -1435,7 +1435,7 @@ def test_hermes_skill_destination_posix_uses_home():
from graphify.__main__ import _platform_skill_destination
with patch("graphify.__main__.platform.system", return_value="Linux"):
dst = _platform_skill_destination("hermes", project=False)
- assert str(dst).endswith(".hermes/skills/graphify/SKILL.md"), dst
+ assert dst == Path.home() / ".hermes" / "skills" / "graphify" / "SKILL.md", dst
def _cli_dispatched_commands() -> set[str]:
diff --git a/tests/test_install_references.py b/tests/test_install_references.py
index ed7090cca4..3e825c9cdb 100644
--- a/tests/test_install_references.py
+++ b/tests/test_install_references.py
@@ -295,7 +295,8 @@ def test_claude_install_ships_lean_core_and_references(tmp_path):
]
-def test_gemini_install_references_all_resolve(tmp_path):
+@pytest.mark.parametrize("system", ["Linux", "Windows"])
+def test_gemini_install_references_all_resolve(tmp_path, monkeypatch, system):
"""End-to-end: every references/ pointer in gemini's installed SKILL.md resolves.
gemini ships claude's lean skill.md body but resolves its references through a
@@ -303,8 +304,12 @@ def test_gemini_install_references_all_resolve(tmp_path):
real claude bundle must leave no dead pointer on disk.
"""
import re
+ # Gemini's user scope is shared .agents on Windows and .gemini on POSIX;
+ # both destinations must retain every real references/ pointer.
+ monkeypatch.setattr(mainmod.platform, "system", lambda: system)
_install(tmp_path, "gemini")
- skill = tmp_path / ".gemini" / "skills" / "graphify" / "SKILL.md"
+ dot_dir = ".agents" if system == "Windows" else ".gemini"
+ skill = tmp_path / dot_dir / "skills" / "graphify" / "SKILL.md"
assert skill.exists()
refdir = skill.parent / "references"
assert refdir.is_dir()
diff --git a/tests/test_install_roundtrip.py b/tests/test_install_roundtrip.py
index 68216e6b3b..2038500c83 100644
--- a/tests/test_install_roundtrip.py
+++ b/tests/test_install_roundtrip.py
@@ -57,6 +57,9 @@ def test_skill_roundtrip_at_real_destination(platform, project, tmp_path, monkey
home.mkdir()
project_dir.mkdir()
monkeypatch.chdir(project_dir)
+ # Hermes owns LOCALAPPDATA on Windows. Keep that native destination in the
+ # same isolated user profile as Path.home instead of the outer home fixture.
+ monkeypatch.setenv("LOCALAPPDATA", str(home / "AppData" / "Local"))
with patch("graphify.__main__.Path.home", return_value=home):
dst = mainmod._platform_skill_destination(
@@ -65,9 +68,11 @@ def test_skill_roundtrip_at_real_destination(platform, project, tmp_path, monkey
# Sanity: a user-scope install must not write under the project dir, and
# vice versa, so the two scopes never collide in this test.
if project:
- assert str(dst).startswith(str(project_dir))
+ assert dst.is_relative_to(project_dir)
else:
- assert str(dst).startswith(str(home))
+ assert dst.is_relative_to(home)
+ if platform == "hermes" and mainmod.platform.system() == "Windows":
+ assert dst == home / "AppData" / "Local" / "hermes" / "skills" / "graphify" / "SKILL.md"
returned = mainmod._copy_skill_file(
platform, project=project, project_dir=project_dir
diff --git a/tests/test_languages.py b/tests/test_languages.py
index 09c3aba3cf..3ebe72c4bb 100644
--- a/tests/test_languages.py
+++ b/tests/test_languages.py
@@ -1,4 +1,4 @@
-"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files, XAML, Robot Framework."""
+"""Language extractor contracts, including the optional Qt 6 QML source profile."""
from __future__ import annotations
from pathlib import Path
import pytest
@@ -5247,3 +5247,23 @@ def test_kotlin_bracketed_annotations_emits_multiple_attribute_edges(tmp_path):
refs = _edge_labels(result, "references", "attribute")
assert ("Foo", "Inject") in refs
assert ("Foo", "VisibleForTesting") in refs
+
+
+def test_qml_language_facade_preserves_source_declarations_and_spans():
+ """REQ-QML-001/003: the optional language facade keeps real QML source facts."""
+ from graphify.extract import extract_qml
+ from graphify.qml_resolution_types import qml_metadata
+ source = FIXTURES / "qml/parser_probe/Main.qml"
+ result = extract_qml(source, root=source.parent)
+ if any(item.get("code") == "QML_PARSER_UNAVAILABLE" for item in result.get("diagnostics", [])):
+ pytest.skip("QML parser not installed (optional [qml] extra)")
+ assert not result.get("error"), result.get("diagnostics")
+ original = source.read_bytes()
+ expected = {("signal", "activated"): b"signal activated(int value, string label)",
+ ("function", "bump"): b"function bump(step: int): int"}
+ for (kind, name), prefix in expected.items():
+ matches = [qml_metadata(node) for node in result["nodes"]
+ if qml_metadata(node).get("kind") == kind and qml_metadata(node).get("raw_name") == name]
+ assert len(matches) == 1
+ span = matches[0]["span"]
+ assert original[span["start_byte"]:span["end_byte"]].startswith(prefix)
diff --git a/tests/test_non_regular_files.py b/tests/test_non_regular_files.py
index 13bc85e738..d436f066f6 100644
--- a/tests/test_non_regular_files.py
+++ b/tests/test_non_regular_files.py
@@ -34,6 +34,7 @@ def test_regular_source_file_is_accepted(tree):
assert _is_regular_file(tree / "src" / "module.py") is True
+@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="native Python has no FIFO creation API")
def test_fifo_is_rejected(tree):
"""The shape that hangs the whole run."""
fifo = tree / "src" / "pipe.py"
@@ -42,6 +43,7 @@ def test_fifo_is_rejected(tree):
assert _is_regular_file(fifo) is False
+@pytest.mark.skipif(not hasattr(socket, "AF_UNIX"), reason="native Python has no AF_UNIX socket API")
def test_unix_socket_is_rejected(tree):
sock_path = tree / "src" / "sock.py"
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@@ -58,14 +60,15 @@ def test_directory_named_like_a_source_file_is_rejected(tree):
assert _is_regular_file(d) is False
-def test_symlink_to_a_regular_file_is_accepted(tree):
+def test_symlink_to_a_regular_file_is_accepted(tree, requires_symlinks):
target = tree / "src" / "module.py"
link = tree / "src" / "alias.py"
link.symlink_to(target)
assert _is_regular_file(link) is True
-def test_symlink_pointing_at_a_fifo_is_rejected(tree):
+@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="native Python has no FIFO creation API")
+def test_symlink_pointing_at_a_fifo_is_rejected(tree, requires_symlinks):
"""A link to a FIFO blocks exactly like the FIFO, so stat must follow it."""
fifo = tree / "src" / "real.py"
os.mkfifo(fifo)
@@ -74,7 +77,7 @@ def test_symlink_pointing_at_a_fifo_is_rejected(tree):
assert _is_regular_file(link) is False
-def test_broken_symlink_is_rejected_without_raising(tree):
+def test_broken_symlink_is_rejected_without_raising(tree, requires_symlinks):
link = tree / "src" / "dangling.py"
link.symlink_to(tree / "src" / "does-not-exist.py")
assert _is_regular_file(link) is False
diff --git a/tests/test_publication.py b/tests/test_publication.py
new file mode 100644
index 0000000000..66dec42c98
--- /dev/null
+++ b/tests/test_publication.py
@@ -0,0 +1,297 @@
+"""INC-QML-25: durable stages, rollback authority and truthful cleanup outcomes."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import shutil
+import stat
+
+import pytest
+
+import graphify.publication as publication
+from graphify.paths import os_replace_with_fallback
+from graphify.publication import ProductPublication
+
+
+NAMES = ("graph.json", "manifest.json", ".graphify_root", ".qt_analysis.json")
+
+
+def seed(root, existing=True):
+ root.mkdir(exist_ok=True)
+ if existing:
+ for name in NAMES:
+ (root / name).write_bytes(("OLD:" + name).encode())
+ return {name: (root / name).read_bytes() if existing else None for name in NAMES}
+
+
+def contents(root):
+ return {name: (root / name).read_bytes() if (root / name).exists() else None for name in NAMES}
+
+
+def stages(transaction):
+ for name in NAMES:
+ transaction.stage(name).write_bytes(("NEW:" + name).encode())
+
+
+@pytest.mark.parametrize("existing", [True, False])
+@pytest.mark.parametrize("phase", ["prepare", "before_replace", "after_replace"])
+def test_inc25_actual_disk_rollback_and_retry(tmp_path, existing, phase):
+ """A serializer/late replacement failure retains all old or absent products."""
+ before = seed(tmp_path, existing)
+
+ def replace(source, destination):
+ if destination.name == "manifest.json" and phase == "before_replace":
+ raise OSError("replace boundary failed")
+ os_replace_with_fallback(source, destination)
+ if destination.name == "manifest.json" and phase == "after_replace":
+ raise OSError("replace completed before external failure")
+
+ with pytest.raises(OSError):
+ with ProductPublication(tmp_path, NAMES, replace=replace) as transaction:
+ stages(transaction)
+ if phase == "prepare":
+ raise OSError("serializer failed")
+ transaction.commit()
+ assert contents(tmp_path) == before
+ assert not list(tmp_path.glob(".gfy-publish-*"))
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ stages(transaction)
+ assert transaction.commit() == NAMES
+ after = contents(tmp_path)
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ stages(transaction)
+ assert transaction.commit() == ()
+ assert contents(tmp_path) == after
+ assert not list(tmp_path.glob(".gfy-publish-*"))
+
+
+@pytest.mark.parametrize("error", [OSError, MemoryError, ValueError])
+def test_inc25_actual_rollback_fault_retains_recovery_copies(tmp_path, monkeypatch, error):
+ """A second actual restoration fault is distinct from fully preserved failure."""
+ before = seed(tmp_path)
+
+ def fail_second(source, destination):
+ if destination.name == "manifest.json":
+ raise OSError("publication failure")
+ os_replace_with_fallback(source, destination)
+
+ def rollback_fault(*_):
+ raise error("restoration failure")
+
+ monkeypatch.setattr(publication, "os_replace_with_fallback", rollback_fault)
+ with pytest.raises(OSError, match="GRAPH_PUBLICATION_RECOVERY"):
+ with ProductPublication(tmp_path, NAMES, replace=fail_second) as transaction:
+ stages(transaction)
+ transaction.commit()
+ assert (tmp_path / "graph.json").read_bytes() == b"NEW:graph.json"
+ retained = list(tmp_path.glob(".gfy-publish-*"))
+ assert len(retained) == 1
+ assert {name: (retained[0] / "old" / name).read_bytes() for name in NAMES} == before
+ # Recovery uses retained authoritative bytes, rather than discarding them
+ # or treating the partly published candidate as a successful cohort.
+ for name in NAMES:
+ os_replace_with_fallback(retained[0] / "old" / name, tmp_path / name)
+ assert contents(tmp_path) == before
+
+
+def test_inc25_real_replace_fallback_failure_restores_cohort(tmp_path, monkeypatch):
+ """The shared copy/rename fallback fails late and restores already published siblings."""
+ before = seed(tmp_path)
+ original_copy = shutil.copy2
+
+ def unavailable(*_):
+ raise PermissionError("shared-folder replace unavailable")
+
+ def failed_fallback(source, target, *args, **kwargs):
+ start, end = Path(source), Path(target)
+ if start.name == "manifest.json" and start.parent.name == "stage" and end.name.startswith(".gfy-replace-"):
+ raise OSError("fallback copy failed")
+ return original_copy(source, target, *args, **kwargs)
+
+ with monkeypatch.context() as failure:
+ failure.setattr(os, "replace", unavailable)
+ failure.setattr(shutil, "copy2", failed_fallback)
+ with pytest.raises(OSError, match="GRAPH_PUBLICATION_FAILED"):
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ stages(transaction)
+ transaction.commit()
+ assert contents(tmp_path) == before
+ assert {path.name for path in tmp_path.iterdir()} == set(NAMES)
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute")
+def test_inc25_unchanged_readonly_products_skip_replacement(tmp_path):
+ """Readonly siblings are valid when their exact accepted bytes need no write."""
+ seed(tmp_path)
+ target = tmp_path / "manifest.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ transaction.stage("graph.json").write_bytes(b"NEW")
+ transaction.stage("manifest.json")
+ assert transaction.commit() == ("graph.json",)
+ assert target.read_bytes() == b"OLD:manifest.json"
+ assert not target.stat().st_mode & stat.S_IWRITE
+ assert not list(tmp_path.glob(".gfy-publish-*"))
+ finally:
+ target.chmod(stat.S_IWRITE)
+
+
+@pytest.mark.parametrize("change", ["changed", "appeared", "missing_stage"])
+def test_inc25_preparation_cannot_overwrite_unrelated_concurrent_change(tmp_path, change):
+ """All originals are validated even when a product has no changed stage."""
+ seed(tmp_path, existing=change != "appeared")
+ with pytest.raises(OSError, match="GRAPH_PUBLICATION_FAILED"):
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ stages(transaction)
+ if change == "missing_stage":
+ transaction.stage("manifest.json").unlink()
+ else:
+ (tmp_path / "manifest.json").write_bytes(b"FOREIGN")
+ transaction.commit()
+ if change != "appeared":
+ assert (tmp_path / "graph.json").read_bytes() == b"OLD:graph.json"
+ else:
+ assert not (tmp_path / "graph.json").exists()
+ if change != "missing_stage":
+ assert (tmp_path / "manifest.json").read_bytes() == b"FOREIGN"
+ assert not list(tmp_path.glob(".gfy-publish-*"))
+
+
+@pytest.mark.parametrize("committed", [False, True])
+@pytest.mark.parametrize("error", [OSError, MemoryError])
+def test_inc25_cleanup_failure_does_not_misreport_cohort_authority(tmp_path, monkeypatch, capsys, committed, error):
+ """Only coherent committed products may be reported successful after cleanup faults."""
+ before = seed(tmp_path)
+ original = ProductPublication._cleanup
+
+ def cleanup_fault(_):
+ raise error("owned scratch is locked")
+
+ monkeypatch.setattr(ProductPublication, "_cleanup", cleanup_fault)
+ if committed:
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ stages(transaction)
+ transaction.commit()
+ assert "GRAPH_PUBLICATION_CLEANUP: committed products retained" in capsys.readouterr().err
+ assert contents(tmp_path) != before
+ else:
+ with pytest.raises(OSError, match="GRAPH_PUBLICATION_CLEANUP: prior products retained"):
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ stages(transaction)
+ raise OSError("serializer failure")
+ assert contents(tmp_path) == before
+ assert len(list(tmp_path.glob(".gfy-publish-*"))) == 1
+ original(transaction)
+ assert not list(tmp_path.glob(".gfy-publish-*"))
+
+
+def test_inc25_symlink_target_write_through_and_duplicate_authority(tmp_path):
+ """Caller symlinks retain identity; two product roles cannot share a real target."""
+ actual = tmp_path / "actual.json"
+ actual.write_bytes(b"OLD")
+ output = tmp_path / "out"
+ output.mkdir()
+ try:
+ (output / "graph.json").symlink_to(actual)
+ (output / "manifest.json").symlink_to(actual)
+ except OSError:
+ pytest.skip("Host symlink creation is unavailable")
+ with pytest.raises(ValueError, match="share one destination"):
+ ProductPublication(output, ("graph.json", "manifest.json"))
+ with ProductPublication(output, ("graph.json",)) as transaction:
+ transaction.stage("graph.json").write_bytes(b"NEW")
+ transaction.commit()
+ assert actual.read_bytes() == b"NEW"
+ assert (output / "graph.json").is_symlink()
+ assert not list(output.glob(".gfy-publish-*"))
+
+
+@pytest.mark.parametrize("names", [("../outside",), ("graph.json", "graph.json"), (".",), ("",),
+ ("graph.json:foreign",), ("a" * 129,), (None,)])
+def test_inc25_inventory_is_bounded_to_owned_product_names(tmp_path, names):
+ """Invalid product roles reject before any scratch or destination writes."""
+ with pytest.raises(ValueError, match="GRAPH_PUBLICATION_FAILED"):
+ ProductPublication(tmp_path, names)
+ assert not list(tmp_path.iterdir())
+
+
+@pytest.mark.parametrize("phase", ["output_dir", "scratch_dir", "child_dir", "snapshot", "canonical_path"])
+def test_inc25_setup_fault_has_safe_code_and_preserves_retry(tmp_path, monkeypatch, phase):
+ """External setup/copy rejection never exposes the exception's path or body."""
+ before = seed(tmp_path)
+ private_marker = "synthetic-sensitive-location"
+ error = PermissionError(13, private_marker + "\nserializer body", str(tmp_path / private_marker))
+ mkdir, copy = Path.mkdir, shutil.copy2
+
+ def failed_mkdir(path, *args, **kwargs):
+ if ((phase == "output_dir" and path == tmp_path)
+ or (phase == "child_dir" and path.name == "old"
+ and path.parent.name.startswith(".gfy-publish-"))):
+ raise error
+ return mkdir(path, *args, **kwargs)
+
+ def failed_copy(source, target, *args, **kwargs):
+ if phase == "snapshot" and Path(target).name == "manifest.json":
+ raise error
+ return copy(source, target, *args, **kwargs)
+
+ def failed_scratch(*args, **kwargs):
+ raise error
+
+ with monkeypatch.context() as failure:
+ failure.setattr(Path, "mkdir", failed_mkdir)
+ failure.setattr(shutil, "copy2", failed_copy)
+ if phase == "scratch_dir":
+ failure.setattr(publication.tempfile, "mkdtemp", failed_scratch)
+ if phase == "canonical_path":
+ failure.setattr(publication.os.path, "realpath", failed_scratch)
+ with pytest.raises(OSError, match="GRAPH_PUBLICATION_FAILED") as rejected:
+ ProductPublication(tmp_path, NAMES)
+ assert private_marker not in str(rejected.value)
+ assert str(tmp_path) not in str(rejected.value)
+ assert rejected.value.__cause__ is error
+ assert contents(tmp_path) == before
+ assert not list(tmp_path.glob(".gfy-publish-*"))
+ with ProductPublication(tmp_path, NAMES) as transaction:
+ stages(transaction)
+ assert transaction.commit() == NAMES
+ assert contents(tmp_path) != before
+ assert not list(tmp_path.glob(".gfy-publish-*"))
+
+
+def test_inc25_partial_setup_cleanup_fault_retains_safe_recovery_evidence(tmp_path, monkeypatch):
+ """Failed scaffold cleanup reports its own safe code and keeps accepted bytes."""
+ before = seed(tmp_path)
+ error = PermissionError(13, "synthetic-sensitive-location", str(tmp_path / "private-fixture"))
+ copy, unlink = shutil.copy2, Path.unlink
+ rejected_cleanup = []
+
+ def reject_copy(source, target, *args, **kwargs):
+ if Path(target).parent.name == "old" and Path(target).name == "manifest.json":
+ raise error
+ return copy(source, target, *args, **kwargs)
+
+ # Patch the owning Path method: Python 3.10 caches its os.unlink accessor.
+ # Witness only this transaction's recovery copy, preserving all others.
+ def reject_cleanup(path, *args, **kwargs):
+ target = Path(path)
+ if (target.parent.name == "old" and target.name == "graph.json"
+ and target.parent.parent.name.startswith(".gfy-publish-")
+ and target.parent.parent.parent == tmp_path):
+ rejected_cleanup.append(target)
+ raise error
+ return unlink(path, *args, **kwargs)
+
+ with monkeypatch.context() as failure:
+ failure.setattr(shutil, "copy2", reject_copy)
+ failure.setattr(Path, "unlink", reject_cleanup)
+ with pytest.raises(OSError, match="GRAPH_PUBLICATION_CLEANUP") as rejected:
+ ProductPublication(tmp_path, NAMES)
+ assert "synthetic-sensitive-location" not in str(rejected.value)
+ assert str(tmp_path) not in str(rejected.value)
+ assert contents(tmp_path) == before
+ retained = list(tmp_path.glob(".gfy-publish-*"))
+ assert len(retained) == 1 and (retained[0] / "old/graph.json").read_bytes() == before["graph.json"]
+ assert rejected_cleanup and set(rejected_cleanup) == {retained[0] / "old/graph.json"}
+ assert rejected.value.__cause__ is error
diff --git a/tests/test_qml_adversarial.py b/tests/test_qml_adversarial.py
new file mode 100644
index 0000000000..497d9d38d2
--- /dev/null
+++ b/tests/test_qml_adversarial.py
@@ -0,0 +1,198 @@
+"""Adversarial production-boundary checks for scoped QML/JS relationship facts."""
+
+from __future__ import annotations
+
+import base64
+import copy
+import json
+
+import pytest
+
+from graphify.extract import extract_js
+from graphify.extractors.qml_ast import parse_source
+from graphify.extractors.qml_declarations import Declarations
+from graphify.extractors.qml_expressions import collect_relationships
+from graphify.extractors.qml_facts import FactBuilder, qml_metadata
+from graphify.extractors.qml_scripts import collect_qml_scripts
+from graphify.qml_relationships import resolve_qml_relationships
+from graphify.security import sanitize_metadata
+
+
+def _facts(root, source, scripts=None):
+ """Exercise the real AST collectors before projection, with no fixture evaluator."""
+ path = root / "Main.qml"
+ path.write_text(source, encoding="utf-8")
+ raw, program, _ = parse_source(path)
+ facts = FactBuilder(path, root, raw)
+ declarations = Declarations(facts)
+ declarations.extract(program)
+ collect_relationships(declarations)
+ per_file = {path: {"nodes": facts.nodes, "edges": facts.edges}}
+ for name, text in (scripts or {}).items():
+ script = root / name
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text(text, encoding="utf-8")
+ per_file[script] = extract_js(script)
+ collect_qml_scripts(list(per_file), list(per_file.values()), root=root)
+ return per_file
+
+
+def _resolve(root, per_file):
+ nodes = [node for result in per_file.values() for node in result["nodes"]]
+ edges = [edge for result in per_file.values() for edge in result["edges"]]
+ resolve_qml_relationships(per_file, nodes, edges, root=root)
+ return nodes, edges
+
+
+def _sites(nodes, kind, reference):
+ return [node for node in nodes if qml_metadata(node).get("kind") == kind and
+ qml_metadata(node).get("reference") == reference]
+
+
+@pytest.mark.parametrize("loop", [
+ "for (let target = 0; target < 1; target++) { target(); }",
+ "for (const target of [1]) { target(); }",
+ "for (let target in {a: 1}) { target(); }",
+])
+def test_loop_local_bindings_shadow_qml_only_inside_loop(tmp_path, loop):
+ """Loop declarations must prevent false same-name QML calls without leaking out."""
+ source = "QtObject { function target() {} function run() { " + loop + " target(); } }"
+ nodes, edges = _resolve(tmp_path, _facts(tmp_path, source))
+ sites = sorted(_sites(nodes, "call", "target"), key=lambda node: qml_metadata(node)["span"]["start_byte"])
+ assert len(sites) == 2
+ assert qml_metadata(sites[0])["status"] == "dynamic"
+ assert qml_metadata(sites[0])["reason"] == "javascript_lexical_binding"
+ assert not any(edge["source"] == sites[0]["id"] and edge["relation"] == "calls" for edge in edges)
+ assert qml_metadata(sites[1])["status"] == "resolved"
+
+
+def test_catch_binding_and_arrow_parameter_do_not_capture_outer_qml_function(tmp_path):
+ """Catch/arrow binders shadow; a sibling call still reaches the QML function."""
+ source = """QtObject {
+ function target() {}
+ function run() {
+ try { throw 1; } catch (target) { target(); }
+ const closure = (target) => target();
+ target();
+ }
+ }"""
+ nodes, _ = _resolve(tmp_path, _facts(tmp_path, source))
+ sites = sorted(_sites(nodes, "call", "target"), key=lambda node: qml_metadata(node)["span"]["start_byte"])
+ assert [qml_metadata(site)["status"] for site in sites] == ["dynamic", "dynamic", "resolved"]
+
+
+def test_for_var_binding_is_function_scoped_and_reassigned_callable_is_dynamic(tmp_path):
+ """Function-wide var and changed local callable values cannot fall back to QML."""
+ source = """QtObject { function target() {} function run() {
+ for (var target of [1]) { target(); } target();
+ let chosen = () => 1; chosen = () => 2; chosen();
+ } }"""
+ nodes, edges = _resolve(tmp_path, _facts(tmp_path, source))
+ sites = _sites(nodes, "call", "target") + _sites(nodes, "call", "chosen")
+ assert len(sites) == 3
+ assert all(qml_metadata(site)["status"] != "resolved" for site in sites)
+ assert not any(edge["source"] in {site["id"] for site in sites} and edge["relation"] == "calls" for edge in edges)
+
+
+def test_assignment_target_is_not_read_but_computed_address_reads_are_retained(tmp_path):
+ """Writes differ from dependency reads, including receiver/index evaluation."""
+ source = """QtObject { id: root; property var store: ({}); property int index: 0;
+ property int value: 1;
+ function run() { value = 2; store[index] = value; value += index; }
+ }"""
+ per_file = _facts(tmp_path, source)
+ nodes, _ = _resolve(tmp_path, per_file)
+ reads = [qml_metadata(node)["reference"] for node in nodes if qml_metadata(node).get("kind") == "read"]
+ assert reads.count("value") == 2
+ assert reads.count("index") == 2
+ assert reads.count("store") == 1
+
+
+def test_many_lexical_bindings_survive_sanitized_json_without_false_resolution(tmp_path):
+ """Transport caps must never turn a proven local binding into a QML reference."""
+ bindings = ", ".join(f"a{i:02} = 0" for i in range(60)) + ", target = 0"
+ per_file = _facts(tmp_path, "QtObject { function target() {} function run() { let " + bindings + "; target(); } }")
+ copied = json.loads(json.dumps(per_file[next(iter(per_file))]))
+ for node in copied["nodes"]:
+ node["metadata"] = sanitize_metadata(node["metadata"])
+ nodes, edges = _resolve(tmp_path, {tmp_path / "Main.qml": copied})
+ site = _sites(nodes, "call", "target")[0]
+ assert qml_metadata(site)["status"] != "resolved"
+ assert not any(edge["source"] == site["id"] and edge["relation"] == "calls" for edge in edges)
+
+
+def test_exported_arrow_helper_and_local_reexport_have_correct_visibility(tmp_path):
+ """ESM export arrows are callable, while re-export clauses do not export locals."""
+ source = 'import "helpers.mjs" as Helpers\nQtObject { function run() { Helpers.arrow(); Helpers.local(); } }'
+ scripts = {"helpers.mjs": "export const arrow = () => 42; function local() { return 1; } export { local } from './other.mjs';",
+ "other.mjs": "export function local() { return 2; }"}
+ nodes, edges = _resolve(tmp_path, _facts(tmp_path, source, scripts))
+ arrow = _sites(nodes, "call", "Helpers.arrow")[0]
+ assert qml_metadata(arrow)["status"] == "resolved"
+ local = _sites(nodes, "call", "Helpers.local")[0]
+ assert qml_metadata(local)["status"] != "resolved"
+ assert not any(edge["source"] == local["id"] and edge["relation"] == "calls" for edge in edges)
+
+
+@pytest.mark.parametrize("library", ["", ".pragma library\n"])
+def test_shared_script_never_binds_to_qml_importer_context(tmp_path, library):
+ """A shared helper has no arbitrary document's ID/function namespace."""
+ source = 'import "shared.js" as Shared\nQtObject { function target() {} function run() { Shared.invoke(); } }'
+ nodes, edges = _resolve(tmp_path, _facts(tmp_path, source, {"shared.js": library + "function invoke() { target(); }"}))
+ helper = _sites(nodes, "call", "target")[0]
+ assert qml_metadata(helper)["status"] != "resolved"
+ assert not any(edge["source"] == helper["id"] and edge["relation"] == "calls" for edge in edges)
+
+
+def test_alias_cycle_and_dynamic_connections_have_no_guessed_endpoints(tmp_path):
+ """Static alias cycles and runtime target selections remain explicit coverage gaps."""
+ source = """QtObject { id: root; property alias first: root.second;
+ property alias second: root.first; property bool choose: true;
+ signal activated(); function run() { first; }
+ Connections { target: choose ? root : null; function onActivated() { root.run(); } }
+ }"""
+ nodes, edges = _resolve(tmp_path, _facts(tmp_path, source))
+ aliases = [node for node in nodes if qml_metadata(node).get("kind") == "alias"]
+ assert aliases and all(qml_metadata(node)["reason"] == "alias_cycle_or_limit" for node in aliases)
+ handler = [node for node in nodes if qml_metadata(node).get("kind") == "handler"][0]
+ assert qml_metadata(handler)["reason"] == "connections_runtime_target"
+ assert not any(edge["source"] == handler["id"] and edge.get("context") == "qml_signal_subscription" for edge in edges)
+
+
+def test_explicit_component_body_id_cannot_escape_into_enclosing_document(tmp_path):
+ """Qt Component's definition body is a separate ID scope from its declaring object."""
+ source = """import QtQml as Qml
+ import QtQuick as Quick
+ Quick.Item { id: root;
+ Qml.Component { id: factory; Qml.QtObject { id: hidden; property int value: 1 } }
+ function run() { hidden.value; }
+ }"""
+ nodes, edges = _resolve(tmp_path, _facts(tmp_path, source))
+ site = _sites(nodes, "read", "hidden.value")[0]
+ assert qml_metadata(site)["status"] != "resolved"
+ assert not any(edge["source"] == site["id"] and edge.get("context") == "qml_binding_read" for edge in edges)
+
+
+def test_mixed_legacy_connections_handlers_do_not_activate_ignored_function_handlers(tmp_path):
+ """Qt ignores function-style handlers when one legacy handler exists in that object."""
+ source = """QtObject { id: root; signal first(); signal second();
+ Connections { target: root; onFirst: {}
+ function onSecond() {}
+ }
+ }"""
+ nodes, edges = _resolve(tmp_path, _facts(tmp_path, source))
+ handlers = [node for node in nodes if qml_metadata(node).get("kind") == "handler"]
+ first = next(node for node in handlers if qml_metadata(node)["reference"] == "first")
+ second = next(node for node in handlers if qml_metadata(node)["reference"] == "second")
+ assert qml_metadata(first)["status"] == "resolved"
+ assert qml_metadata(second)["status"] != "resolved"
+ assert not any(edge["source"] == second["id"] and edge.get("context") == "qml_signal_subscription" for edge in edges)
+
+
+@pytest.mark.parametrize("raw", ["not-a-map", {"raw_name": "!not-base64"},
+ {"raw_name": base64.b64encode(b"x" * 500).decode()}])
+def test_corrupt_semantic_transport_is_bounded_rejection(raw):
+ """Malformed/bypassed field transport must reject rather than reinterpret a fact."""
+ node = {"metadata": {"qml": {"contract_version": 1, "raw_name": "safe", "raw_values": copy.deepcopy(raw)}}}
+ with pytest.raises(ValueError):
+ qml_metadata(node)
diff --git a/tests/test_qml_cli_persistence.py b/tests/test_qml_cli_persistence.py
new file mode 100644
index 0000000000..f12b3bdb18
--- /dev/null
+++ b/tests/test_qml_cli_persistence.py
@@ -0,0 +1,94 @@
+"""Production CLI QML rejection, real graph retention, retry and scope refresh."""
+import json
+
+import pytest
+
+import graphify.__main__ as mainmod
+import graphify.extract as ex
+from tests.qml_persistence_helpers import failing_qml, healthy_qml, make_project, protected_snapshot
+
+
+def _run(monkeypatch, project, *, no_cluster=True, flags=()):
+ argv = ["graphify", "extract", str(project), "--code-only", *flags]
+ if no_cluster:
+ argv.append("--no-cluster")
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(mainmod.sys, "argv", argv)
+ try:
+ mainmod.main()
+ except SystemExit as exc:
+ return exc.code
+ return 0
+
+
+@pytest.mark.parametrize("kind", ["missing", "incompatible", "read", "partial"])
+@pytest.mark.parametrize("no_cluster", [False, True])
+@pytest.mark.parametrize("extra_nodes", [0, 8], ids=["equal-count-edge-loss", "larger-candidate"])
+def test_cli_qml_failure_preserves_graph_and_manifest_despite_override(
+ tmp_path, monkeypatch, capsys, kind, no_cluster, extra_nodes,
+):
+ """QML-001/002/011: completed products survive error even at equal/higher counts."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _run(monkeypatch, project) == 0
+ out = project / "graphify-out"
+ before = protected_snapshot(out)
+ assert "manifest.json" in before
+ graph = json.loads(before["graph.json"])
+ assert graph.get("links", graph.get("edges"))
+ (project / "Main.qml").write_text("Item { property int changed: 2 }\n", encoding="utf-8")
+ monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml(kind, extra_nodes=extra_nodes))
+
+ assert _run(monkeypatch, project, no_cluster=no_cluster, flags=("--force", "--allow-partial")) == 1
+ assert protected_snapshot(out) == before
+ assert "QML_GRAPH_PRESERVED" in capsys.readouterr().err
+
+
+def test_cli_failed_first_scan_publishes_no_graph_or_manifest(tmp_path, monkeypatch):
+ """An unavailable QML parser cannot publish a successful Python-only graph."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("missing"))
+ assert _run(monkeypatch, project, flags=("--allow-partial",)) == 1
+ out = project / "graphify-out"
+ assert not (out / "graph.json").exists()
+ assert not (out / "manifest.json").exists()
+
+
+def test_cli_retry_uses_changed_qml_and_settles_after_recovery(tmp_path, monkeypatch):
+ """Retaining the prior manifest leaves failed source edits pending for retry."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _run(monkeypatch, project) == 0
+ source = project / "Main.qml"
+ source.write_text("import QtQuick\nItem { property int count: 2 }\n", encoding="utf-8")
+ monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("partial"))
+ before = protected_snapshot(project / "graphify-out")
+ assert _run(monkeypatch, project) == 1
+ assert protected_snapshot(project / "graphify-out") == before
+
+ calls = []
+ def recovered(path):
+ calls.append(path.name)
+ return healthy_qml(path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", recovered)
+ assert _run(monkeypatch, project) == 0
+ assert calls == ["Main.qml"]
+ calls.clear()
+ assert _run(monkeypatch, project) == 0
+ assert calls == []
+
+
+def test_cli_qml_change_refreshes_unchanged_code_facts(tmp_path, monkeypatch):
+ """QML-011 initial policy supplies the whole live code corpus to resolution."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _run(monkeypatch, project) == 0
+ (project / "Main.qml").write_text("Item { property int changed: 1 }\n", encoding="utf-8")
+ called = []
+ real = ex.extract
+ def record(paths, **kwargs):
+ called.extend(p.name for p in paths)
+ return real(paths, **kwargs)
+ monkeypatch.setattr(ex, "extract", record)
+ assert _run(monkeypatch, project) == 0
+ assert set(called) == {"Main.qml", "keep.py"}
diff --git a/tests/test_qml_cpp_access.py b/tests/test_qml_cpp_access.py
new file mode 100644
index 0000000000..d15f3eac31
--- /dev/null
+++ b/tests/test_qml_cpp_access.py
@@ -0,0 +1,87 @@
+"""QML-017 literal loaders, scoped handles and objectName rejection boundaries."""
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis, sites
+
+QML = '''import QtQml
+QtObject {
+ id: root
+ property string title: "hello"
+ function refresh() {}
+ signal closed()
+ property QtObject child: QtObject { id: childId; objectName: "details"; property int count: 1 }
+}
+'''
+
+
+def test_view_root_and_literal_object_name_property_access(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use() {{
+ QQuickView view;
+ view.setSource(QUrl("{url}"));
+ auto root = view.rootObject();
+ root->property("title");
+ auto child = root->findChild("details");
+ child->setProperty("count", 2);
+ QMetaObject::invokeMethod(root, "refresh");
+}}'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML})
+ assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved"
+ assert qt_metadata(sites(result, "qml_root")[0])["status"] == "resolved"
+ accesses = sites(result, "qml_access")
+ assert len(accesses) == 4
+ assert all(qt_metadata(node)["status"] == "resolved" for node in accesses), [qt_metadata(node) for node in accesses]
+
+
+def test_qml_id_is_not_object_name_and_computed_lookup_not_guessed(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(QString name) {{ QQuickView view; view.setSource(QUrl("{url}"));
+ auto root = view.rootObject(); root->findChild("childId"); root->findChild(name); }}'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML})
+ first, second = sites(result, "qml_access")
+ assert qt_metadata(first)["reason"] == "object_name_unavailable"
+ assert qt_metadata(second)["status"] == "dynamic"
+
+
+def test_relative_runtime_url_and_reassigned_handle_remain_unresolved(tmp_path):
+ source = '''void use() { QQuickView view; view.setSource(QUrl("Main.qml"));
+ auto root = view.rootObject(); root = other(); root->property("title"); }'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML})
+ assert qt_metadata(sites(result, "qml_load")[0])["status"] != "resolved"
+ assert qt_metadata(sites(result, "qml_access")[0])["status"] != "resolved"
+
+
+def test_duplicate_object_names_do_not_select_first_child(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ qml = QML.replace('property int count: 1 }', 'property int count: 1 }\n property QtObject other: QtObject { objectName: "details" }')
+ source = f'''void use() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject(); root->findChild("details"); }}'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ assert qt_metadata(sites(result, "qml_access")[0])["status"] == "ambiguous"
+
+
+def test_engine_module_loader_root_selection_and_plain_component_create(tmp_path):
+ source = '''void use() { QQmlApplicationEngine engine; engine.loadFromModule("App", "Main");
+ auto root = engine.rootObjects().first(); root->property("title"); }'''
+ result = analysis(tmp_path, {"access.cpp": source, "App/Main.qml": QML, "App/qmldir": "module App\nMain 1.0 Main.qml\n"})
+ assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved"
+ assert qt_metadata(sites(result, "qml_root")[0])["status"] == "resolved"
+ assert qt_metadata(sites(result, "qml_access")[0])["status"] == "resolved"
+ opaque = analysis(tmp_path, {"access.cpp": source.replace('.first()', '.last()'), "App/Main.qml": QML, "App/qmldir": "module App\nMain 1.0 Main.qml\n"})
+ assert qt_metadata(sites(opaque, "qml_root")[0])["status"] == "unsupported"
+ url = (tmp_path / "Main.qml").as_uri()
+ component = f'''void use() {{ QQmlEngine engine; QQmlComponent component(&engine, QUrl("{url}"));
+ auto root = component.create(); root->property("title"); }}'''
+ created = analysis(tmp_path, {"access.cpp": component, "Main.qml": QML})
+ assert qt_metadata(sites(created, "qml_root")[0])["status"] == "resolved"
+ assert qt_metadata(sites(created, "qml_access")[0])["status"] == "resolved"
+
+
+def test_literal_names_are_unicode_and_comments_templates_are_not_evidence(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ qml = QML.replace('"details"', '"détail"')
+ source = f'''// view.setSource(QUrl("{url}"));
+void use() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject();
+ root->findChild("détail"); const char *text = R"(root->property("title"))"; }}'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ assert len(sites(result, "qml_load")) == 1
+ assert len(sites(result, "qml_access")) == 1
+ assert qt_metadata(sites(result, "qml_access")[0])["status"] == "resolved"
diff --git a/tests/test_qml_declarations.py b/tests/test_qml_declarations.py
new file mode 100644
index 0000000000..7fdb9f6318
--- /dev/null
+++ b/tests/test_qml_declarations.py
@@ -0,0 +1,131 @@
+"""QML-01 declarations, source evidence and rejection at the production boundary."""
+from __future__ import annotations
+
+from collections import Counter
+
+import pytest
+
+from graphify.extractors.qml import extract_qml
+from graphify.validate import validate_extraction
+from tests.qml_test_helpers import DECLARATIONS, DECLARATION_KINDS, assert_contains, assert_spans, by_kind, canonical, one, qml, write_qml
+
+
+def test_qml003_ac01_exact_declarations_ownership_and_raw_types(tmp_path):
+ """Members/imports belong to source owners; no body-derived guesses are emitted."""
+ path = write_qml(tmp_path)
+ result = extract_qml(path, root=tmp_path)
+ assert not result.get("error")
+ assert Counter(qml(node)["kind"] for node in result["nodes"] if qml(node)["kind"] in DECLARATION_KINDS) == {
+ "file": 1, "component": 1, "object": 2, "property": 6,
+ "signal": 1, "function": 1, "import": 4,
+ }
+ assert validate_extraction(result) == []
+ file_node, component = one(result, "file"), one(result, "component")
+ assert qml(component)["raw_name"] == "Main"
+ objects = {qml(node)["object_id"]: node for node in by_kind(result, "object")}
+ assert set(objects) == {"root", "editor"}
+ assert qml(objects["root"])["type_name"] == "Item"
+ assert qml(objects["editor"])["type_name"] == "Controls.TextField"
+ assert_contains(result, file_node, component)
+ assert_contains(result, component, objects["root"])
+ assert_contains(result, objects["root"], objects["editor"])
+ root_properties = {"count": "int", "title": "string", "doubled": "int", "extras": "list", "editorText": "alias"}
+ for name, raw_type in root_properties.items():
+ node = one(result, "property", name)
+ assert qml(node)["raw_type"] == raw_type
+ assert qml(node)["parent_scope_key"] == qml(objects["root"])["object_scope_key"]
+ assert_contains(result, objects["root"], node)
+ assert_contains(result, objects["editor"], one(result, "property", "caption"))
+ for kind, name in (("signal", "activated"), ("function", "bump")):
+ assert_contains(result, objects["root"], one(result, kind, name))
+ for name, modifier in (("title", "required"), ("doubled", "readonly"), ("extras", "default")):
+ assert modifier in qml(one(result, "property", name))["modifiers"]
+ assert not result.get("raw_calls")
+ # QML-03 may add independently tested semantic sites/edges; this QML-01 test
+ # remains exact about declarations and their source-backed ownership.
+ assert any(edge["relation"] == "contains" for edge in result["edges"])
+ assert all(edge["confidence"] == "EXTRACTED" for edge in result["edges"] if edge["relation"] == "contains")
+ assert all(qml(node)["contract_version"] == 1 for node in result["nodes"])
+ assert_spans(result, path.read_bytes())
+
+
+def test_qml01_import_facts_preserve_kind_value_qualifier_and_versions(tmp_path):
+ """Raw import facts retain evidence without invented module/component targets."""
+ result = extract_qml(write_qml(tmp_path), root=tmp_path)
+ imports = by_kind(result, "import")
+ expected = [("module", "QtQuick", None, None, None), ("module", "QtQuick.Controls", "Controls", 6, 5), ("directory", "widgets", "Local", None, None), ("script", "helpers.js", "Helpers", None, None)]
+ actual = [tuple(qml(node).get(key) for key in ("import_kind", "value", "qualifier", "major", "minor")) for node in imports]
+ assert actual == expected
+ for node in imports:
+ assert_contains(result, one(result, "file"), node)
+
+
+def test_qml003_ac03_comments_literals_groups_and_js_inner_functions_are_not_objects(tmp_path):
+ """Grouped properties have their own kind; fake and nested-JS declarations stay opaque."""
+ source = '''import QtQuick
+Item {
+ property string example: "Rectangle { id: fake }"
+ /* Button { id: phantom } */
+ function calculate() { function inner() { return 3; } return inner(); }
+ anchors { left: parent.left; topMargin: 4 }
+ Text { id: label; font { pixelSize: 12; bold: true } }
+}
+'''
+ result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path)
+ assert not result.get("error")
+ assert [qml(node)["type_name"] for node in by_kind(result, "object")] == ["Item", "Text"]
+ groups = by_kind(result, "property_group")
+ assert [qml(node)["raw_name"] for node in groups] == ["anchors", "font"]
+ assert all(qml(node)["raw_type"] == "" for node in groups)
+ assert [qml(node)["raw_name"] for node in by_kind(result, "function")] == ["calculate"]
+ assert len(by_kind(result, "property")) == 1
+ assert not result.get("raw_calls")
+
+
+def test_qml003_ac01_unicode_crlf_spans_and_original_names(tmp_path):
+ """Production facts preserve original bytes and Unicode identifiers."""
+ source = 'import QtQuick\r\nItem {\r\n property string café: "雪😀"; property int après: 2\r\n}\r\n'
+ path = write_qml(tmp_path, source=source)
+ result = extract_qml(path, root=tmp_path)
+ assert not result.get("error")
+ assert [qml(node)["raw_name"] for node in by_kind(result, "property")] == ["café", "après"]
+ assert_spans(result, path.read_bytes())
+ span = qml(one(result, "property", "après"))["span"]
+ prefix = source.encode("utf-8").split(b"\r\n")[2][:span["start_column"]]
+ assert span["start_column"] == len(prefix) > len(prefix.decode("utf-8"))
+
+
+def test_qml003_ac04_repeated_valid_extraction_is_identical(tmp_path):
+ """Same production invocation retains node, edge, evidence and diagnostic facts."""
+ path = write_qml(tmp_path)
+ assert canonical(extract_qml(path, root=tmp_path)) == canonical(extract_qml(path, root=tmp_path))
+
+
+@pytest.mark.parametrize("source", ['Item { property int kept: 1; function unfinished() {', 'Item { property int broken: @@@; property string kept: "safe" }'])
+def test_qml012_ac01_malformed_or_partial_parse_has_no_authoritative_nodes(tmp_path, source):
+ """Recovery cannot publish earlier declarations as a complete successful result."""
+ result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path)
+ assert result.get("error")
+ assert result["nodes"] == [] and result["edges"] == []
+ assert result.get("diagnostics")
+ assert all(str(tmp_path) not in str(diagnostic) for diagnostic in result["diagnostics"])
+
+
+def test_qml001_ac04_empty_source_is_distinguishable_from_parse_failure(tmp_path):
+ """Empty input retains its file fact and explicit informational coverage status."""
+ result = extract_qml(write_qml(tmp_path, source=""), root=tmp_path)
+ assert not result.get("error") and len(result["nodes"]) == 1
+ file_node = one(result, "file")
+ assert qml(file_node)["empty"] is True and result["edges"] == []
+ assert any(diagnostic["code"] == "QML_EMPTY" for diagnostic in result["diagnostics"])
+ assert qml(file_node)["span"]["start_byte"] == qml(file_node)["span"]["end_byte"] == 0
+
+
+def test_qml003_ac01_source_slices_match_hand_checked_declarations(tmp_path):
+ """Reported spans enclose the named declaration, not an unrelated member/body."""
+ path = write_qml(tmp_path)
+ result = extract_qml(path, root=tmp_path)
+ source = DECLARATIONS.encode("utf-8")
+ for kind, name, prefix in (("property", "count", b"property int count"), ("property", "title", b"required property string title"), ("signal", "activated", b"signal activated"), ("function", "bump", b"function bump")):
+ span = qml(one(result, kind, name))["span"]
+ assert source[span["start_byte"]:span["end_byte"]].startswith(prefix)
diff --git a/tests/test_qml_expressions.py b/tests/test_qml_expressions.py
new file mode 100644
index 0000000000..855908ab31
--- /dev/null
+++ b/tests/test_qml_expressions.py
@@ -0,0 +1,92 @@
+"""QML-006-AC01..04 static source reads, aliases, rejection and lossless sites."""
+
+import json
+
+import networkx as nx
+
+from graphify.build import build_from_json
+from graphify.extractors.qml_facts import qml_metadata
+from tests.qml_expression_helpers import extraction, nodes, single, target_edges
+
+
+def test_binding_reads_and_nested_qualified_aliases(tmp_path):
+ result = extraction(tmp_path, {"Main.qml": """QtObject {
+ id: root
+ property int total: child.value + child.value
+ property alias selected: child
+ property alias nested: root.selected.value
+ property QtObject childObject: QtObject { id: child; property int value: 3 }
+}"""})
+ value = single(result, "property", "value")
+ repeated = nodes(result, "read", "child.value")
+ assert len(repeated) == 2
+ assert len({node["id"] for node in repeated}) == 2
+ for site in repeated:
+ assert target_edges(result, site)[0]["target"] == value["id"]
+ assert qml_metadata(site)["span"]["end_byte"] > qml_metadata(site)["span"]["start_byte"]
+ nested = single(result, "alias", "nested")
+ assert target_edges(result, nested, "qml_alias_target")[0]["target"] == value["id"]
+ assert qml_metadata(nested)["evidence"]
+
+
+def test_alias_cycles_missing_and_ambiguous_targets_never_guess(tmp_path):
+ result = extraction(tmp_path, {"Main.qml": """QtObject {
+ id: root
+ property alias a: root.b
+ property alias b: root.a
+ property alias missing: unknown.value
+ property alias duplicate: root.value
+ property int value: 1
+ property int value: 2
+}"""})
+ for name, status in [("a", "unsupported"), ("b", "unsupported"),
+ ("missing", "unavailable"), ("duplicate", "ambiguous")]:
+ site = single(result, "alias", name)
+ assert qml_metadata(site)["status"] == status
+ assert qml_metadata(site)["reason"]
+ assert not target_edges(result, site)
+
+
+def test_component_and_object_scope_do_not_bind_hidden_identifiers(tmp_path):
+ result = extraction(tmp_path, {"Main.qml": """QtObject {
+ id: root
+ property int value: 1
+ property int total: child.value
+ property QtObject childObject: QtObject { id: child; property int value: 2 }
+ component Inner: QtObject { property int hidden: child.value }
+}"""})
+ reads = nodes(result, "read", "child.value")
+ assert len(reads) == 2
+ assert sorted(qml_metadata(site)["status"] for site in reads) == ["resolved", "unavailable"]
+ resolved = [site for site in reads if qml_metadata(site)["status"] == "resolved"][0]
+ assert target_edges(result, resolved)[0]["target"] in {n["id"] for n in nodes(result, "property", "value")}
+ hidden = [site for site in reads if qml_metadata(site)["status"] != "resolved"][0]
+ assert not target_edges(result, hidden)
+
+
+def test_dynamic_reads_and_executable_source_are_only_analyzed(tmp_path):
+ marker = tmp_path / "must-not-exist"
+ result = extraction(tmp_path, {"Main.qml": f"""QtObject {{
+ property int value: provider()[key]
+ function provider() {{ return {{}}; }}
+ function dangerous() {{ require('fs').writeFileSync('{marker.as_posix()}', 'bad'); }}
+}}"""})
+ dynamic = [site for site in nodes(result, "read") if qml_metadata(site)["status"] == "dynamic"]
+ assert dynamic and all(not target_edges(result, site) for site in dynamic)
+ assert nodes(result, "call", "provider")
+ assert not marker.exists()
+
+
+def test_repeated_sites_survive_actual_directed_build_and_json(tmp_path):
+ result = extraction(tmp_path, {"Main.qml": "QtObject { id: root; property int value: 1; property int sum: value + value }"})
+ sites = nodes(result, "read", "value")
+ graph = build_from_json(result, directed=True, root=tmp_path)
+ assert isinstance(graph, nx.DiGraph)
+ value = single(result, "property", "value")
+ for site in sites:
+ assert graph.has_edge(site["id"], value["id"])
+ portable = nx.node_link_graph(json.loads(json.dumps(nx.node_link_data(graph))))
+ assert len(sites) == 2
+ for site in sites:
+ assert portable.has_edge(site["id"], value["id"])
+ assert qml_metadata(portable.nodes[site["id"]])["span"] == qml_metadata(site)["span"]
diff --git a/tests/test_qml_failures.py b/tests/test_qml_failures.py
new file mode 100644
index 0000000000..81c9e6be8d
--- /dev/null
+++ b/tests/test_qml_failures.py
@@ -0,0 +1,98 @@
+"""QML-01 rejection and optional/native parser boundaries without unsafe facts."""
+from __future__ import annotations
+
+import builtins
+import json
+
+import pytest
+import tree_sitter_language_pack
+
+from graphify.extract import extract_python
+from graphify.extractors.qml import extract_qml
+from tests.qml_test_helpers import assert_no_machine_paths, write_qml
+
+
+def assert_rejected(result, code, root):
+ """Rejected source cannot masquerade as a cacheable authoritative extraction."""
+ assert result.get("error")
+ assert result["nodes"] == [] and result["edges"] == []
+ assert any(diagnostic["code"] == code for diagnostic in result["diagnostics"])
+ assert_no_machine_paths(result, root)
+
+
+def test_qml001_ac03_missing_optional_import_is_safe_and_does_not_break_python(tmp_path, monkeypatch):
+ """Actual optional import rejection produces QML diagnostics; unrelated parsing works."""
+ original_import = builtins.__import__
+
+ def without_qml_parser(name, *args, **kwargs):
+ if name == "tree_sitter_language_pack":
+ raise ModuleNotFoundError("Synthetic unavailable optional parser")
+ return original_import(name, *args, **kwargs)
+
+ path = write_qml(tmp_path)
+ python_path = tmp_path / "safe.py"
+ python_path.write_text("def safe():\n return 1\n", encoding="utf-8")
+ monkeypatch.setattr(builtins, "__import__", without_qml_parser)
+ result = extract_qml(path, root=tmp_path)
+ assert_rejected(result, "QML_PARSER_MISSING", tmp_path)
+ assert any(node["label"] == "safe()" for node in extract_python(python_path, root=tmp_path)["nodes"])
+
+
+def test_qml001_ac03_incompatible_parser_load_is_bounded_failure(tmp_path, monkeypatch):
+ """Binding/ABI load failures are rejected with safe diagnostics, not backend details."""
+ def incompatible(_grammar):
+ raise RuntimeError("synthetic-backend-detail must not escape")
+
+ monkeypatch.setattr(tree_sitter_language_pack, "get_parser", incompatible)
+ result = extract_qml(write_qml(tmp_path), root=tmp_path)
+ assert_rejected(result, "QML_PARSER_LOAD", tmp_path)
+ assert "synthetic-backend-detail" not in json.dumps(result)
+
+
+def test_qml001_ac04_unsupported_annotated_root_is_not_file_only_success(tmp_path):
+ """A grammar-recognized shape outside the adapter profile needs explicit rejection."""
+ path = write_qml(tmp_path, source="@Unknown {} Item { property int retained: 1 }\n")
+ # The grammar accepts this shape; the declaration adapter must own its coverage
+ # decision instead of publishing a successful file with all declarations lost.
+ assert not tree_sitter_language_pack.get_parser("qmljs").parse(path.read_bytes()).root_node.has_error
+ result = extract_qml(path, root=tmp_path)
+ assert result.get("error") and result.get("diagnostics")
+ assert result["nodes"] == [] and result["edges"] == []
+
+
+def test_qml012_ac01_native_parse_exception_is_explicit_safe_failure(tmp_path, monkeypatch):
+ """A failing external parser cannot escape or publish private backend exception text."""
+ class FailingParser:
+ def parse(self, _source):
+ raise RuntimeError("synthetic-backend-detail must not escape")
+
+ monkeypatch.setattr(tree_sitter_language_pack, "get_parser", lambda _grammar: FailingParser())
+ result = extract_qml(write_qml(tmp_path), root=tmp_path)
+ assert result.get("error") and result.get("diagnostics")
+ assert result["nodes"] == [] and result["edges"] == []
+ assert "synthetic-backend-detail" not in json.dumps(result)
+
+
+@pytest.mark.parametrize("source", [b"Item { property string value: '\xff' }", b"//" + b"a" * 5_000_001], ids=["invalid-utf8", "oversized"])
+def test_qml012_ac04_unreadable_or_oversized_source_has_safe_failure(tmp_path, source):
+ """Decode/size limits reject input before publishing any partial declarations."""
+ path = tmp_path / "Main.qml"
+ path.write_bytes(source)
+ result = extract_qml(path, root=tmp_path)
+ assert_rejected(result, "QML_READ" if b"\xff" in source else "QML_LIMIT", tmp_path)
+
+
+def test_qml012_ac04_deep_source_terminates_at_supported_bound(tmp_path):
+ """Deep but grammar-valid objects do not recurse through unbounded extraction."""
+ result = extract_qml(write_qml(tmp_path, source="Item {" * 270 + "}" * 270), root=tmp_path)
+ assert_rejected(result, "QML_LIMIT", tmp_path)
+
+
+def test_qml012_ac04_explicit_root_rejection_does_not_expose_absolute_paths(tmp_path):
+ """An out-of-corpus file is rejected without serializing a machine-specific path."""
+ allowed, outside = tmp_path / "allowed", tmp_path / "outside"
+ allowed.mkdir()
+ result = extract_qml(write_qml(outside), root=allowed)
+ assert result.get("error") and result["nodes"] == []
+ assert_no_machine_paths(result, outside)
+ assert_no_machine_paths(result, allowed)
diff --git a/tests/test_qml_graph.py b/tests/test_qml_graph.py
new file mode 100644
index 0000000000..acbbf6786e
--- /dev/null
+++ b/tests/test_qml_graph.py
@@ -0,0 +1,71 @@
+"""QML-01 mixed-language, graph projection and spawned-worker boundaries."""
+from __future__ import annotations
+
+from concurrent.futures import ProcessPoolExecutor
+import json
+import multiprocessing
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import _extract_single_file, extract
+from graphify.extractors.qml import extract_qml
+from graphify.paths import load_node_link_graph
+from graphify.validate import validate_extraction
+from tests.qml_test_helpers import DECLARATION_KINDS, by_kind, canonical, qml, write_qml
+
+
+def test_qml010_ac02_directed_build_and_reload_keep_all_ownership_and_qml_metadata(tmp_path):
+ """Existing graph assembly preserves distinct source-backed declarations and edges."""
+ extraction = extract_qml(write_qml(tmp_path), root=tmp_path)
+ graph = build_from_json(extraction, directed=True, root=tmp_path)
+ assert graph.is_directed()
+ assert set(graph) == {node["id"] for node in extraction["nodes"]}
+ expected = {(edge["source"], edge["target"], edge["relation"]) for edge in extraction["edges"]}
+ assert {(source, target, data["relation"]) for source, target, data in graph.edges(data=True)} == expected
+ target = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(target)) is True
+ serialized = json.loads(target.read_text(encoding="utf-8"))
+ restored = load_node_link_graph(serialized)
+ assert set(restored) == set(graph)
+ assert all(restored.nodes[node["id"]]["metadata"]["qml"] == node["metadata"]["qml"] for node in extraction["nodes"])
+ assert all(restored.edges[source, target]["confidence"] == edge["confidence"] for edge in extraction["edges"] for source, target in [(edge["source"], edge["target"])])
+
+
+def test_qml003_ac02_same_stem_cpp_js_and_qml_do_not_merge(tmp_path):
+ """Generic same-stem producers cannot overwrite hashed QML file/member ownership."""
+ qml_path = write_qml(tmp_path, "Panel.qml", "Item { property int value: 1; function run() { return value; } }\n")
+ cpp_path, js_path = tmp_path / "Panel.cpp", tmp_path / "Panel.js"
+ cpp_path.write_text("int run() { return 1; }\n", encoding="utf-8")
+ js_path.write_text("export function run() { return 2; }\n", encoding="utf-8")
+ direct = extract_qml(qml_path, root=tmp_path)
+ batch = extract([qml_path, cpp_path, js_path], cache_root=tmp_path / "cache", root=tmp_path, parallel=False)
+ qml_nodes = [node for node in batch["nodes"] if node.get("metadata", {}).get("qml", {}).get("kind") in DECLARATION_KINDS]
+ direct_nodes = [node for node in direct["nodes"] if qml(node)["kind"] in DECLARATION_KINDS]
+ assert len(qml_nodes) == len(direct_nodes)
+ assert {node["id"]: qml(node) for node in qml_nodes} == {node["id"]: qml(node) for node in direct_nodes}
+ assert {node["source_file"] for node in batch["nodes"]} >= {"Panel.qml", "Panel.cpp", "Panel.js"}
+ assert validate_extraction(batch) == []
+
+
+def test_qml003_ac04_batch_filesystem_order_and_warm_cache_do_not_change_facts(tmp_path):
+ """Production cache/reordering cannot remove declarations or scope metadata."""
+ paths = [write_qml(tmp_path, relative) for relative in ("left/Main.qml", "right/Main.qml")]
+ kwargs = {"cache_root": tmp_path / "cache", "root": tmp_path, "parallel": False}
+ cold = extract(paths, **kwargs)
+ warm = extract(list(reversed(paths)), **kwargs)
+ assert canonical(cold) == canonical(warm)
+ assert len(by_kind(cold, "component")) == 2
+ assert len({node["id"] for node in cold["nodes"]}) == len(cold["nodes"])
+
+
+def test_qml003_ac04_spawned_worker_retains_explicit_root_and_scope_ids(tmp_path):
+ """A real spawn worker runs the registered production path, without fallback fakes."""
+ path = write_qml(tmp_path, "ui/Main.qml")
+ direct = extract_qml(path, root=tmp_path)
+ context = multiprocessing.get_context("spawn")
+ work = (0, str(path), str(tmp_path), str(tmp_path / "worker-cache"))
+ with ProcessPoolExecutor(max_workers=2, mp_context=context) as pool:
+ index, result = pool.submit(_extract_single_file, work).result(timeout=45)
+ assert index == 0 and canonical(result) == canonical(direct)
+ assert all(node["source_file"] == "ui/Main.qml" for node in result["nodes"])
+ assert len({qml(node)["component_key"] for node in by_kind(result, "object")}) == 1
diff --git a/tests/test_qml_handler_formals.py b/tests/test_qml_handler_formals.py
new file mode 100644
index 0000000000..d2e5939d01
--- /dev/null
+++ b/tests/test_qml_handler_formals.py
@@ -0,0 +1,104 @@
+"""REQ-QML-007-AC03: handler formals own their names across production boundaries."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract
+from graphify.extractors.qml import extract_qml
+from graphify.paths import load_node_link_graph
+from graphify.qml_resolution_types import qml_metadata
+from tests.qt_analysis_helpers import analysis
+
+
+def qml(style, parameters="int count"):
+ """Legacy injection and renamed explicit formals observe different dependencies."""
+ handler = {
+ "legacy": "onReady: { count; }",
+ "arrow": "onReady: (renamed) => { count; renamed; }",
+ "anonymous": "onReady: function(renamed) { count; renamed; }",
+ "connections": "property Connections observer: Connections { target: root; "
+ "function onReady(renamed) { count; renamed; } }",
+ }[style]
+ return "\ufeff// café\r\nimport QtQml\r\nQtObject { id: root; property int count: 1; " + \
+ f"signal ready({parameters}); {handler} }}\r\n"
+
+
+@pytest.mark.parametrize("style", ["legacy", "arrow", "anonymous", "connections"])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml007_ac03_source_handler_formals_preserve_property_dependency(tmp_path, style, directed):
+ """Direct facts carry authority; facade, build and reload retain exact bindings."""
+ source = qml(style)
+ result = analysis(tmp_path, {"Main.qml": source})
+ direct = extract_qml(tmp_path / "Main.qml", root=tmp_path)
+ handler = next(node for node in direct["nodes"] if qml_metadata(node).get("kind") == "handler")
+ md = qml_metadata(handler)
+ assert md["implicit_parameters"] is (style == "legacy")
+ span = md["span"]
+ original = source.encode()[span["start_byte"]:span["end_byte"]]
+ assert original.startswith(b"function onReady" if style == "connections" else b"onReady")
+ assert handler["source_file"] == "Main.qml" and handler["source_location"].startswith("L3")
+ direct_reads = {qml_metadata(node)["reference"]: qml_metadata(node) for node in direct["nodes"]
+ if qml_metadata(node).get("kind") == "read"}
+ assert direct_reads["count"]["lexical_shadowed"] is (style == "legacy")
+ if style != "legacy":
+ assert direct_reads["renamed"]["lexical_shadowed"] is True
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ output = tmp_path / "handler.json"
+ assert to_json(graph, {}, str(output), force=True)
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ reads = {qml_metadata(node)["reference"]: (identity, qml_metadata(node))
+ for identity, node in restored.nodes(data=True) if qml_metadata(node).get("kind") == "read"}
+ identity, count = reads["count"]
+ assert count["status"] == ("dynamic" if style == "legacy" else "resolved")
+ read_edges = [(a, b, data) for a, b, data in restored.edges(data=True)
+ if data.get("context") == "qml_binding_read" and data.get("_src", a) == identity]
+ assert len(read_edges) == (0 if style == "legacy" else 1)
+ if style != "legacy":
+ assert reads["renamed"][1]["status"] == "dynamic"
+ assert qml_metadata(restored.nodes[count["resolved_target_id"]])["raw_name"] == "count"
+ assert restored.has_edge(identity, count["resolved_target_id"])
+ subscriptions = [data for _, _, data in restored.edges(data=True) if data.get("context") == "qml_signal_subscription"]
+ assert len(subscriptions) == 1
+
+
+@pytest.mark.parametrize("style", ["legacy", "arrow"])
+def test_req_qml007_ac03_handler_authority_survives_parameter_display_limit(tmp_path, style):
+ """The last supported signal parameter retains exact lexical authority."""
+ names = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVW"
+ parameters = ", ".join([f"int {name}" for name in names] + ["int count"])
+ result = analysis(tmp_path, {"Main.qml": qml(style, parameters)})
+ read = next(qml_metadata(node) for node in result["nodes"]
+ if qml_metadata(node).get("kind") == "read" and qml_metadata(node).get("reference") == "count")
+ assert read["status"] == ("dynamic" if style == "legacy" else "resolved")
+ assert read["lexical_shadowed"] is (style == "legacy")
+
+
+@pytest.mark.parametrize("style", ["legacy", "arrow"])
+def test_req_qml007_ac03_signal_parameter_overflow_rejects_without_truncation(tmp_path, style):
+ """Unsupported signatures fail closed before a hidden binder can resolve."""
+ names = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX"
+ parameters = ", ".join([f"int {name}" for name in names] + ["int count"])
+ path = tmp_path / "Main.qml"
+ path.write_text(qml(style, parameters), encoding="utf-8", newline="")
+ direct = extract_qml(path, root=tmp_path)
+ facade = extract([path], root=tmp_path, parallel=False)
+ assert direct["qml_failures"][0]["code"] == "QML_LIMIT"
+ assert facade["qml_failures"][0]["code"] == "QML_LIMIT"
+ assert not direct["nodes"] and not facade["nodes"]
+
+
+def test_req_qml007_ac03_handler_change_does_not_reclassify_unrelated_javascript(tmp_path):
+ """Ordinary JavaScript retains its generic callable IDs and source metadata."""
+ script = tmp_path / "keep.js"
+ script.write_text("function helper(count) { return count; }\n", encoding="utf-8")
+ before = extract([script], root=tmp_path, cache_root=tmp_path / ".before", parallel=False)
+ after = analysis(tmp_path, {"Main.qml": qml("arrow"), "keep.js": script.read_text(encoding="utf-8")})
+ def selected(result):
+ return ([node for node in result["nodes"] if node.get("source_file") == "keep.js"],
+ [edge for edge in result["edges"] if edge.get("source_file") == "keep.js"])
+ assert selected(after) == selected(before)
+ assert not any(qml_metadata(node) for node in selected(after)[0])
diff --git a/tests/test_qml_handlers.py b/tests/test_qml_handlers.py
new file mode 100644
index 0000000000..fef7423def
--- /dev/null
+++ b/tests/test_qml_handlers.py
@@ -0,0 +1,107 @@
+"""QML-007-AC01/03 handler subscriptions, emits and lexical JS visibility."""
+
+from graphify.extractors.qml_facts import qml_metadata
+from tests.qml_expression_helpers import extraction, nodes, single, target_edges
+
+
+def test_declared_and_property_change_handlers_are_subscriptions(tmp_path):
+ result = extraction(tmp_path, {"Main.qml": """QtObject {
+ id: root
+ property int value: 1
+ signal ping(int value)
+ function work(n) { return n; }
+ onPing: { work(value); }
+ onValueChanged: { work(value); }
+ function publish() { ping(value); }
+}"""})
+ signal = single(result, "signal", "ping")
+ handler = single(result, "handler", "onPing")
+ edge = target_edges(result, handler, "qml_signal_subscription")[0]
+ assert edge["relation"] == "references" and edge["target"] == signal["id"]
+ notify = single(result, "property_change_signal")
+ assert target_edges(result, single(result, "handler", "onValueChanged"))[0]["target"] == notify["id"]
+ emission = single(result, "call", "ping")
+ assert target_edges(result, emission, "qml_signal_emit")[0]["relation"] == "uses"
+ parameter_reads = [site for site in nodes(result, "read", "value")
+ if "value" in qml_metadata(site)["lexical_names"]]
+ assert len(parameter_reads) == 1
+ assert qml_metadata(parameter_reads[0])["reason"] == "javascript_lexical_binding"
+
+
+def test_connections_target_and_dynamic_target_stay_distinct(tmp_path):
+ result = extraction(tmp_path, {"Main.qml": """QtObject {
+ id: root
+ signal ping(int n)
+ function work(n) { return n; }
+ property QtObject connection: Connections {
+ target: root
+ function onPing(n) { work(n); }
+ }
+ property QtObject dynamicConnection: Connections {
+ target: chooseTarget()
+ onPing: { work(1); }
+ }
+}"""})
+ handlers = nodes(result, "handler", "onPing")
+ assert len(handlers) == 2
+ fixed = [site for site in handlers if qml_metadata(site)["connection_target"] == "root"][0]
+ assert target_edges(result, fixed)[0]["target"] == single(result, "signal", "ping")["id"]
+ dynamic = [site for site in handlers if not qml_metadata(site)["connection_target"]][0]
+ assert qml_metadata(dynamic)["status"] == "dynamic"
+ assert not target_edges(result, dynamic)
+
+
+def test_parameters_block_bindings_nested_functions_and_computed_calls(tmp_path):
+ result = extraction(tmp_path, {"Main.qml": """QtObject {
+ function work() { return 1; }
+ function run(work) { work(); }
+ function outer() {
+ { let work = 3; work(); }
+ work();
+ function local() { work(); }
+ local();
+ object[name]();
+ }
+}"""})
+ calls = nodes(result, "call", "work")
+ assert len(calls) == 4
+ assert sorted(qml_metadata(site)["status"] for site in calls) == ["dynamic", "dynamic", "resolved", "resolved"]
+ local = single(result, "call", "local")
+ assert target_edges(result, local, "qml_js_call")[0]["target"] == single(result, "js_function", "local")["id"]
+ computed = single(result, "call", "dynamic")
+ assert qml_metadata(computed)["reason"] == "computed_or_runtime_target"
+ assert not target_edges(result, computed)
+
+
+def test_inherited_and_alias_target_signal_parameters_shadow_properties(tmp_path):
+ result = extraction(tmp_path, {"Base.qml": "QtObject { signal ping(int value) }",
+ "Main.qml": """Base {
+ id: root
+ property int value: 1
+ property alias chosen: child
+ property Base childObject: Base { id: child }
+ function helper(n) { return n; }
+ onPing: { helper(value); }
+ property QtObject subscription: Connections {
+ target: root.chosen
+ onPing: { helper(value); }
+ }
+}"""})
+ signal = single(result, "signal", "ping", "Base.qml")
+ assert qml_metadata(signal)["parameter_names"] == ["value"]
+ for handler in nodes(result, "handler", "onPing"):
+ assert target_edges(result, handler)[0]["target"] == signal["id"]
+ reads = nodes(result, "read", "value", "Main.qml")
+ assert len(reads) == 2
+ assert all(qml_metadata(site)["reason"] == "javascript_lexical_binding" for site in reads)
+ assert all(not target_edges(result, site) for site in reads)
+
+
+def test_signal_parameter_transport_limit_is_explicit_failure(tmp_path):
+ from graphify.extractors.qml import extract_qml
+ path = tmp_path / "Main.qml"
+ parameters = ", ".join(f"int n{i}" for i in range(51))
+ path.write_text("QtObject { signal ping(" + parameters + ") }", encoding="utf-8")
+ result = extract_qml(path, root=tmp_path)
+ assert result["qml_failures"] == [{"code": "QML_LIMIT", "source_file": "Main.qml"}]
+ assert not result["nodes"]
diff --git a/tests/test_qml_identity.py b/tests/test_qml_identity.py
new file mode 100644
index 0000000000..74f8770d01
--- /dev/null
+++ b/tests/test_qml_identity.py
@@ -0,0 +1,126 @@
+"""QML-01 identity contracts: raw spelling, owner scope and explicit scan root."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.extractors.qml import extract_qml
+from tests.qml_test_helpers import DECLARATION_KINDS, assert_contains, by_kind, canonical, one, qml, write_qml
+
+
+def test_qml003_ac04_relocated_root_and_cwd_preserve_all_facts(tmp_path, monkeypatch):
+ """Explicit root is authoritative even when process cwd and absolute checkout change."""
+ first_root, second_root, cwd = (tmp_path / name for name in ("first-checkout", "second-checkout", "elsewhere"))
+ first = write_qml(first_root, "ui/Main.qml")
+ second = write_qml(second_root, "ui/Main.qml")
+ cwd.mkdir()
+ before = extract_qml(first, root=first_root)
+ monkeypatch.chdir(cwd)
+ after = extract_qml(second, root=second_root)
+ assert canonical(before) == canonical(after)
+ assert all(node["source_file"] == "ui/Main.qml" for node in before["nodes"])
+ assert all(edge["source_file"] == "ui/Main.qml" for edge in before["edges"])
+ assert first_root.as_posix() not in canonical(before)
+ assert second_root.as_posix() not in canonical(after)
+
+
+def test_qml003_ac04_comment_insert_changes_spans_but_not_named_identity(tmp_path):
+ """Moving a declaration down a line does not replace its graph identity."""
+ source = 'Item { id: root; property int count: 1; function bump() { return count; } }\n'
+ path = write_qml(tmp_path, source=source)
+ before = extract_qml(path, root=tmp_path)
+ write_qml(tmp_path, source="// Added line only\n" + source)
+ after = extract_qml(path, root=tmp_path)
+ # Named declarations use semantic identity; expression occurrences retain
+ # their documented spans and may change identity when source offsets move.
+ assert {node["id"] for node in before["nodes"] if qml(node)["kind"] in DECLARATION_KINDS} == {
+ node["id"] for node in after["nodes"] if qml(node)["kind"] in DECLARATION_KINDS}
+ for kind, name in (("property", "count"), ("function", "bump")):
+ old, new = one(before, kind, name), one(after, kind, name)
+ assert old["id"] == new["id"]
+ assert qml(new)["span"]["start_row"] == qml(old)["span"]["start_row"] + 1
+ assert qml(old)["component_key"] == qml(new)["component_key"]
+ assert qml(old)["object_scope_key"] == qml(new)["object_scope_key"]
+
+
+def test_qml003_ac02_duplicate_basename_uses_full_relative_path(tmp_path):
+ """Same file/member names under different directories cannot share identities."""
+ results = [extract_qml(write_qml(tmp_path, relative), root=tmp_path) for relative in ("left/widgets/Panel.qml", "right/widgets/Panel.qml")]
+ identities = [{node["id"] for node in result["nodes"]} for result in results]
+ assert identities[0].isdisjoint(identities[1])
+ assert qml(one(results[0], "component"))["component_key"] != qml(one(results[1], "component"))["component_key"]
+
+
+@pytest.mark.parametrize("first_name,second_name", [("Panel.qml", "panel.qml"), ("a-b.qml", "a_b.qml"), ("a.b.qml", "a_b.qml"), ("A.qml", "A.qml")])
+def test_qml010_ac01_filename_normalization_collisions_keep_distinct_ids(tmp_path, first_name, second_name):
+ """Separate roots permit case-distinct path spelling checks on Windows filesystems."""
+ results = []
+ for index, name in enumerate((first_name, second_name)):
+ root = tmp_path / f"checkout-{index}"
+ results.append(extract_qml(write_qml(root, name), root=root))
+ assert {node["id"] for node in results[0]["nodes"]}.isdisjoint({node["id"] for node in results[1]["nodes"]})
+ assert one(results[0], "file")["source_file"] == first_name
+ assert one(results[1], "file")["source_file"] == second_name
+
+
+def test_qml003_ac02_unicode_normalization_colliding_members_remain_distinct(tmp_path):
+ """NFKC-equivalent original identifier spellings must retain separate facts."""
+ source = 'Item { property int café: 1; property int cafe\u0301: 2 }\n'
+ result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path)
+ assert not result.get("error")
+ properties = by_kind(result, "property")
+ assert [qml(node)["raw_name"] for node in properties] == ["café", "cafe\u0301"]
+ assert len({node["id"] for node in properties}) == 2
+
+
+def test_qml003_ac02_inline_component_equal_ids_and_members_have_separate_owners(tmp_path):
+ """Equal IDs in root/inline component namespaces are not global property names."""
+ source = '''Item {
+ id: shared
+ property int value: 1
+ function run() { return value; }
+ component Tile: Item {
+ id: shared
+ property int value: 2
+ function run() { return value; }
+ }
+}
+'''
+ result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path)
+ assert not result.get("error")
+ inline = one(result, "inline_component", "Tile")
+ objects = by_kind(result, "object")
+ assert len(objects) == 2 and all(qml(node)["object_id"] == "shared" for node in objects)
+ assert len({qml(node)["component_key"] for node in objects}) == 2
+ assert len({qml(node)["object_scope_key"] for node in objects}) == 2
+ inner = next(node for node in objects if qml(node)["component_key"] == qml(inline)["component_key"])
+ assert_contains(result, inline, inner)
+ for kind in ("property", "function"):
+ members = by_kind(result, kind)
+ assert len(members) == 2 and len({node["id"] for node in members}) == 2
+ assert {qml(node)["parent_scope_key"] for node in members} == {qml(node)["object_scope_key"] for node in objects}
+ assert not any(qml(node)["raw_name"] == "id" for node in by_kind(result, "property"))
+
+
+def test_qml01_ui_suffix_names_component_without_losing_filename_identity(tmp_path):
+ """Designer .ui.qml exports Panel while keeping Panel.qml's file scope separate."""
+ normal = extract_qml(write_qml(tmp_path, "Panel.qml"), root=tmp_path)
+ designer = extract_qml(write_qml(tmp_path, "Panel.ui.qml"), root=tmp_path)
+ assert qml(one(normal, "component"))["raw_name"] == qml(one(designer, "component"))["raw_name"] == "Panel"
+ assert one(designer, "file")["source_file"] == "Panel.ui.qml"
+ assert {node["id"] for node in normal["nodes"]}.isdisjoint({node["id"] for node in designer["nodes"]})
+
+
+def test_qml010_ac02_metadata_and_keys_survive_json_round_trip_without_paths(tmp_path):
+ """Nested scope facts are plain portable values, not Path objects or machine roots."""
+ result = extract_qml(write_qml(tmp_path, "ui/Main.qml"), root=tmp_path)
+ encoded = json.dumps(result, ensure_ascii=False)
+ reloaded = json.loads(encoded)
+ assert canonical(reloaded) == canonical(result)
+ for node in reloaded["nodes"]:
+ data = qml(node)
+ for key in ("component_key", "object_scope_key", "parent_scope_key"):
+ value = data.get(key)
+ if value is not None:
+ assert tmp_path.as_posix() not in value and str(tmp_path) not in value
diff --git a/tests/test_qml_integration.py b/tests/test_qml_integration.py
new file mode 100644
index 0000000000..9232c4d2ad
--- /dev/null
+++ b/tests/test_qml_integration.py
@@ -0,0 +1,211 @@
+"""Real QML-02 admission, project joins, workers and conservative update parity."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+import graphify.extract as ex
+from graphify.build import build_from_json
+from graphify.detect import FileType, classify_file
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_metadata import extract_qmldir
+from graphify.paths import load_node_link_graph
+from graphify.watch import _rebuild_code
+from tests.qml_test_helpers import canonical, write_qml
+
+
+def project(root):
+ """Two accepted providers let a qmldir-only edit change an unchanged caller's target."""
+ write_qml(root, "Public/Tools/Old.qml", "QtObject { property int oldValue: 1 }\n")
+ write_qml(root, "Public/Tools/New.qml", "QtObject { property int newValue: 2 }\n")
+ metadata = root / "Public/Tools/qmldir"
+ metadata.write_text("module Public.Tools\nButton 1.0 Old.qml\n", encoding="utf-8")
+ write_qml(root, source="import Public.Tools 1.0\nButton { id: root }\n")
+ return metadata
+
+
+def type_target(result, file="Main.qml", label="Button"):
+ """Follow the actual source-owned resolution site to its exact provider endpoint."""
+ site = [node for node in result["nodes"] if node["source_file"] == file and
+ qml_metadata(node).get("kind") == "type_use" and node["label"] == label]
+ assert len(site) == 1 and qml_metadata(site[0])["status"] == "resolved"
+ links = [edge for edge in result["edges"] if edge["source"] == site[0]["id"] and edge["relation"] == "uses"]
+ assert len(links) == 1
+ return links[0]["target"]
+
+
+def portable_graph(graph):
+ """Compare promised QML facts, excluding writer-owned community/timestamp fields."""
+ nodes = {identity: qml_metadata(data) for identity, data in graph.nodes(data=True)}
+ edges = {(data.get("_src", source), data.get("_tgt", target), data["relation"], data.get("context"), data["confidence"]) for source, target, data in graph.edges(data=True)}
+ return nodes, edges
+
+
+def test_qml002_ac02_named_qmldir_dispatch_directory_and_single_file_root(tmp_path):
+ """Extensionless metadata reaches the actual adapter with the same explicit root."""
+ metadata = project(tmp_path)
+ assert classify_file(metadata) == FileType.CODE
+ assert ex._get_extractor(metadata) is extract_qmldir
+ assert ex.collect_files(metadata, root=tmp_path) == [metadata]
+ files = ex.collect_files(tmp_path, root=tmp_path)
+ assert metadata in files
+ result = ex.extract(files, root=tmp_path, cache_root=tmp_path / "cache", parallel=False)
+ target = type_target(result)
+ assert next(node for node in result["nodes"] if node["id"] == target)["source_file"] == "Public/Tools/Old.qml"
+ assert not result.get("qml_failures")
+
+
+@pytest.mark.parametrize("ignore_file", [".gitignore", ".graphifyignore"])
+def test_qml002_ac03_named_metadata_obeys_project_ignores(tmp_path, ignore_file):
+ """An ignored provider cannot become visible merely because the file exists on disk."""
+ metadata = project(tmp_path)
+ (tmp_path / ignore_file).write_text("Public/Tools/qmldir\n", encoding="utf-8")
+ files = ex.collect_files(tmp_path, root=tmp_path)
+ assert metadata not in files
+ result = ex.extract(files, root=tmp_path, cache_root=tmp_path / "cache", parallel=False)
+ sites = [node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "type_use"]
+ assert len(sites) == 1 and qml_metadata(sites[0])["status"] != "resolved"
+ assert not any(edge["source"] == sites[0]["id"] and edge["relation"] == "uses" for edge in result["edges"])
+
+
+def test_qml002_ac03_explicit_single_metadata_entry_obeys_root_ignore(tmp_path):
+ """Directory and single-file admission use the same approved root ignore policy."""
+ metadata = project(tmp_path)
+ (tmp_path / ".graphifyignore").write_text("Public/Tools/qmldir\n", encoding="utf-8")
+ assert ex.collect_files(metadata, root=tmp_path) == []
+
+
+@pytest.mark.parametrize("follow", [False, True])
+def test_qml004_ac04_external_symlink_metadata_never_expands_corpus(tmp_path, follow):
+ """Even explicit symlink following cannot grant an outside module provider access."""
+ root, external = tmp_path / "accepted", tmp_path / "outside"
+ root.mkdir()
+ external.mkdir()
+ outside = external / "qmldir"
+ outside.write_text("module Public.Tools\nButton 1.0 Button.qml\n", encoding="utf-8")
+ link = root / "qmldir"
+ try:
+ link.symlink_to(outside)
+ except (OSError, NotImplementedError):
+ pytest.skip("Host does not permit creating symlinks")
+ assert ex.collect_files(root, root=root, follow_symlinks=follow) == []
+ assert ex.collect_files(link, root=root, follow_symlinks=follow) == []
+ assert extract_qmldir(link, root=root).get("error")
+
+
+def test_qml010_ac01_real_process_pool_and_warm_order_match_sequential(tmp_path, monkeypatch):
+ """More than twenty public sources exercise the actual pool, not its sequential fallback."""
+ paths = [write_qml(tmp_path, f"components/Panel{index}.qml", f"Item {{ id: root; property int value: {index} }}\n") for index in range(24)]
+ pool_results = []
+ real_pool = ex._extract_parallel
+
+ def observe_pool(*args, **kwargs):
+ ran = real_pool(*args, **kwargs)
+ pool_results.append(ran)
+ return ran
+
+ monkeypatch.setattr(ex, "_extract_parallel", observe_pool)
+ parallel = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "pool-cache", parallel=True, max_workers=2)
+ assert pool_results == [True]
+ sequential = ex.extract(list(reversed(paths)), root=tmp_path, cache_root=tmp_path / "serial-cache", parallel=False)
+ warm = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "serial-cache", parallel=False)
+ assert canonical(parallel) == canonical(sequential) == canonical(warm)
+
+
+def test_qml004_project_join_borrows_unchanged_context_without_mutating_or_returning_it(tmp_path):
+ """A changed caller resolves through accepted provider context while its owners stay borrowed."""
+ project(tmp_path)
+ paths = ex.collect_files(tmp_path, root=tmp_path)
+ full = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "full-cache", parallel=False)
+ context_nodes = [node for node in full["nodes"] if node["source_file"] != "Main.qml"]
+ context_ids = {node["id"] for node in context_nodes}
+ context_edges = [edge for edge in full["edges"] if edge["source"] in context_ids and edge["target"] in context_ids]
+ before = copy.deepcopy((context_nodes, context_edges))
+ caller = ex.extract([tmp_path / "Main.qml"], root=tmp_path, cache_root=tmp_path / "caller-cache", parallel=False,
+ resolution_context_nodes=context_nodes, resolution_context_edges=context_edges)
+ assert (context_nodes, context_edges) == before
+ assert all(node["source_file"] == "Main.qml" for node in caller["nodes"])
+ assert type_target(caller) == type_target(full)
+ assert type_target(caller) in context_ids
+
+
+def test_qml011_ac02_qmldir_only_update_refreshes_unchanged_caller_and_matches_clean_build(tmp_path, monkeypatch):
+ """Real update publishes the new provider, removes its old edge and rechecks the live corpus."""
+ metadata = project(tmp_path)
+ assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False)
+ graph_path = tmp_path / "graphify-out/graph.json"
+ initial = json.loads(graph_path.read_text(encoding="utf-8"))
+ before_target = type_target({"nodes": initial["nodes"], "edges": initial.get("edges", initial.get("links"))})
+ metadata.write_text("module Public.Tools\nButton 1.0 New.qml\n", encoding="utf-8")
+ called = []
+ real_extract = ex.extract
+
+ def observe_extract(paths, **kwargs):
+ called.extend(paths)
+ return real_extract(paths, **kwargs)
+
+ monkeypatch.setattr(ex, "extract", observe_extract)
+ assert _rebuild_code(tmp_path, changed_paths=[metadata], no_cluster=True, acquire_lock=False)
+ assert tmp_path / "Main.qml" in called and metadata in called
+ updated = json.loads(graph_path.read_text(encoding="utf-8"))
+ updated_edges = updated.get("edges", updated.get("links"))
+ after_target = type_target({"nodes": updated["nodes"], "edges": updated_edges})
+ assert after_target != before_target
+ assert next(node for node in updated["nodes"] if node["id"] == after_target)["source_file"] == "Public/Tools/New.qml"
+ clean = real_extract(ex.collect_files(tmp_path, root=tmp_path), root=tmp_path, cache_root=tmp_path / "clean-cache", parallel=False)
+ assert portable_graph(load_node_link_graph(updated)) == portable_graph(build_from_json(clean, root=tmp_path))
+
+
+def test_qml010_ac02_default_undirected_export_reload_keeps_qml_direction(tmp_path):
+ """Default graph consumers retain each QML relationship's actual source and target."""
+ project(tmp_path)
+ result = ex.extract(ex.collect_files(tmp_path, root=tmp_path), root=tmp_path,
+ cache_root=tmp_path / "cache", parallel=False)
+ graph = build_from_json(result, root=tmp_path)
+ assert not graph.is_directed()
+ target = tmp_path / "default-graph.json"
+ assert to_json(graph, {}, str(target))
+ serialized = json.loads(target.read_text(encoding="utf-8"))
+ # The JSON source/target direction and the reloaded consumer direction are
+ # independent boundaries: an undirected adjacency iteration is insufficient.
+ expected = {(edge["source"], edge["target"], edge["relation"], edge.get("context")) for edge in result["edges"]}
+ assert {(edge["source"], edge["target"], edge["relation"], edge.get("context")) for edge in serialized["links"]} == expected
+ restored = load_node_link_graph(serialized)
+ assert not restored.is_directed()
+ assert portable_graph(restored) == portable_graph(graph)
+
+
+@pytest.mark.parametrize("extension", ["js", "mjs"])
+@pytest.mark.parametrize("same_stem", [False, True])
+def test_qml010_ac02_literal_script_import_keeps_generic_file_endpoint_after_build_reload(tmp_path, extension, same_stem):
+ """A resolved QML import must survive the cross-language graph projection guard."""
+ filename = f"helpers.{extension}"
+ source = "function help() { return 1; }\n" if extension == "js" else "export function help() { return 1; }\n"
+ helper = write_qml(tmp_path, filename, source)
+ caller = write_qml(tmp_path, source=f'import "{filename}" as Helpers\nQtObject {{ property int value: Helpers.help() }}\n')
+ paths = [caller, helper]
+ if same_stem:
+ other_extension = "mjs" if extension == "js" else "js"
+ paths.append(write_qml(tmp_path, f"helpers.{other_extension}", "function help() { return 2; }\n"))
+ result = ex.extract(paths, root=tmp_path, cache_root=tmp_path / "cache", parallel=False)
+ targets = [node for node in result["nodes"] if node["source_file"] == filename and node["label"] == filename]
+ sites = [node for node in result["nodes"] if node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "import_resolution"]
+ assert len(targets) == len(sites) == 1
+ assert qml_metadata(sites[0])["status"] == "resolved"
+ expected = [edge for edge in result["edges"] if edge["source"] == sites[0]["id"] and edge["target"] == targets[0]["id"]]
+ assert len(expected) == 1 and expected[0]["relation"] == "imports"
+ graph = build_from_json(result, directed=True, root=tmp_path)
+ assert graph.has_edge(sites[0]["id"], targets[0]["id"])
+ target = tmp_path / "roundtrip.json"
+ assert to_json(graph, {}, str(target))
+ restored = load_node_link_graph(json.loads(target.read_text(encoding="utf-8")))
+ # Directed JSON deliberately carries direction in its source/target fields;
+ # its writer need not retain the auxiliary undirected loader markers.
+ assert restored.is_directed()
+ edge = restored.edges[sites[0]["id"], targets[0]["id"]]
+ assert (edge["relation"], edge["confidence"]) == ("imports", "INFERRED")
+ assert edge.get("_src", sites[0]["id"]) == sites[0]["id"]
+ assert edge.get("_tgt", targets[0]["id"]) == targets[0]["id"]
diff --git a/tests/test_qml_metadata.py b/tests/test_qml_metadata.py
new file mode 100644
index 0000000000..e71ee1a097
--- /dev/null
+++ b/tests/test_qml_metadata.py
@@ -0,0 +1,115 @@
+"""QML-004/009 metadata boundary: literal records, safe failures and provenance."""
+
+import json
+
+import pytest
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_metadata import MAX_METADATA_BYTES, extract_qmldir, parse_qmldir
+from graphify.security import sanitize_metadata
+
+
+def test_qmldir_literal_records_and_utf8_offsets():
+ """Supported metadata retains independent records beyond sanitizer's list cap."""
+ source = "# α\nmodule Public.Tools\n" + "".join(f"Widget{i} 1.0 Widget{i}.qml\n" for i in range(60))
+ source += "singleton State 1.1 State.qml\ninternal Hidden Hidden.qml\n"
+ source += "import Public.Base auto\ndepends Public.Runtime 1.0\noptional plugin tools\n"
+ source += "typeinfo tools.qmltypes\nclassname ToolsPlugin\ndesignersupported\n"
+ result = parse_qmldir(source, relative_file="Public/Tools/qmldir")
+ assert not result.get("error")
+ assert len([node for node in result["nodes"] if qml_metadata(node).get("kind") == "export"]) == 62
+ module = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "module")
+ md = qml_metadata(module)
+ assert source.encode()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"module Public.Tools"
+ assert md["span"]["start_row"] == 1
+ assert all(edge["confidence"] == "EXTRACTED" for edge in result["edges"])
+ payload = json.loads(json.dumps(result))
+ for node in payload["nodes"]:
+ node["metadata"] = sanitize_metadata(node["metadata"])
+ assert [qml_metadata(node).get("raw_name") for node in payload["nodes"]] == [
+ qml_metadata(node).get("raw_name") for node in result["nodes"]]
+
+
+@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"])
+@pytest.mark.parametrize("newline", [b"\n", b"\r\n"])
+@pytest.mark.parametrize("leading_comment", [False, True])
+def test_qmldir_reader_preserves_original_byte_spans(tmp_path, bom, newline, leading_comment):
+ """Production reads preserve UTF-8 BOM, Unicode and CRLF in every span."""
+ lines = ["module Public.Tools", "Widget 1.0 Écran.qml", "# β", "typeinfo types.qmltypes"]
+ if leading_comment:
+ lines.insert(0, "# α")
+ raw = bom + newline.join(line.encode("utf-8") for line in lines) + newline
+ path = tmp_path / "qmldir"
+ path.write_bytes(raw)
+ result = extract_qmldir(path, root=tmp_path)
+ direct = parse_qmldir(raw.decode("utf-8"))
+ assert not result.get("error")
+ assert result == direct
+ spans = [qml_metadata(item)["span"] for item in result["nodes"] + result["edges"]]
+ file_span = qml_metadata(result["nodes"][0])["span"]
+ assert (file_span["start_byte"], file_span["end_byte"]) == (0, len(raw))
+ for span in spans:
+ for end in ("start", "end"):
+ prefix = raw[:span[f"{end}_byte"]]
+ assert span[f"{end}_row"] == prefix.count(b"\n")
+ assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1
+ expected = [line.encode("utf-8") for line in lines if not line.startswith("#")]
+ assert [raw[span["start_byte"]:span["end_byte"]] for span in spans[1:len(result["nodes"])]] == expected
+ plain = parse_qmldir(newline.join(line.encode("utf-8") for line in lines).decode("utf-8"))
+ assert [node["id"] for node in result["nodes"]] == [node["id"] for node in plain["nodes"]]
+
+
+@pytest.mark.parametrize("source,reason", [
+ ("Widget 1.0 Widget.qml\nmodule Bad.Order", "invalid_module_declaration"),
+ ("module Public.Tools\nmodule Public.Tools", "invalid_module_declaration"),
+ ("module Public.Tools\nWidget 1.0 A.qml\nWidget 1.0 B.qml", "duplicate_export"),
+ ("module Public.Tools\ninternal Hidden 1.0 Hidden.qml", "unsupported_or_malformed_directive"),
+ ("module Public.Tools\nimport Public.Base mystery", "invalid_module_version"),
+ ("module Public.Tools\nplugin " + "a" * 400, "metadata_value_limit"),
+ ("module Public.Tools\nunknown executable --args", "unsupported_or_malformed_directive"),
+ ("module Public.Tools\nimport", "invalid_module_dependency"),
+ ("module Public.Tools\noptional broken", "unsupported_or_malformed_directive"),
+])
+def test_qmldir_partial_failure_is_explicit(source, reason):
+ """Malformed/unsupported input must not be reported as complete coverage."""
+ result = parse_qmldir(source)
+ assert result["partial"] is True
+ assert result["error"].startswith("QML-META-001")
+ assert reason in {item["reason"] for item in result["parse_errors"]}
+ assert result["diagnostics"][0]["severity"] == "error"
+
+
+def test_qmldir_read_containment_and_size_limits(tmp_path):
+ """Declarations never authorize following target files, plugins or host paths."""
+ root = tmp_path / "accepted"
+ root.mkdir()
+ outside = tmp_path / "qmldir"
+ outside.write_text("module Outside\n")
+ assert extract_qmldir(outside, root=root)["error"].startswith("QML-META-002")
+ path = root / "qmldir"
+ path.write_bytes(b"x" * (MAX_METADATA_BYTES + 1))
+ assert extract_qmldir(path, root=root)["nodes"] == []
+ path.write_bytes(b"\xff")
+ assert extract_qmldir(path, root=root)["error"].startswith("QML-META-002")
+ path.write_text("module Public.Tools\nWidget 1.0 ../../host.qml\nplugin arbitrary\n")
+ result = extract_qmldir(path, root=root)
+ assert not result.get("error")
+ assert all(node["source_file"] == "qmldir" for node in result["nodes"])
+
+
+def test_qmldir_ids_are_portable_and_exact_case_sensitive():
+ """Case-colliding names and harmless line shifts preserve declaration identity."""
+ source = "module Public.Tools\nWidget 1.0 Widget.qml\nwidget 1.0 widget.qml"
+ before = parse_qmldir(source)
+ after = parse_qmldir("# editor comment\n" + source)
+ assert [node["id"] for node in before["nodes"]] == [node["id"] for node in after["nodes"]]
+ assert len({node["id"] for node in before["nodes"]}) == len(before["nodes"])
+
+
+def test_qmldir_record_limit_is_reported(monkeypatch):
+ """Work rejection retains a diagnostic and never claims complete coverage."""
+ monkeypatch.setattr("graphify.extractors.qml_metadata.MAX_METADATA_RECORDS", 2)
+ result = parse_qmldir("module Public.Tools\nA 1.0 A.qml\nB 1.0 B.qml")
+ assert result["parse_errors"][-1]["reason"] == "metadata_record_limit"
+ result = parse_qmldir("x" * (MAX_METADATA_BYTES + 1))
+ assert result["parse_errors"][-1]["reason"] == "metadata_size_limit"
diff --git a/tests/test_qml_parser_probe.py b/tests/test_qml_parser_probe.py
new file mode 100644
index 0000000000..650d95aec1
--- /dev/null
+++ b/tests/test_qml_parser_probe.py
@@ -0,0 +1,234 @@
+"""QML-00 parser characterization; no Graphify language support is registered.
+
+These independently authored fixtures exercise the pinned parser through the
+Language/Parser primitives used by Graphify. They do not execute Qt or corpus JS,
+resolve modules, validate qmldir semantics, or verify production requirements.
+"""
+from __future__ import annotations
+
+import importlib
+from importlib.metadata import version
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+from types import ModuleType
+
+import pytest
+from tree_sitter import LANGUAGE_VERSION, MIN_COMPATIBLE_LANGUAGE_VERSION, Language, Parser
+
+from tests.qml_probe_helpers import OFFLINE_PROBE, field_text, nodes, point_at, text, walk
+
+FIXTURES = Path(__file__).parent / "fixtures" / "qml" / "parser_probe"
+VALID_QML = ("Main.qml", "Modern68.qml", "Grouped.qml", "Script.qml", "UnicodeCRLF.qml", "Imports.qml", "plugins.qmltypes")
+
+
+@pytest.fixture(scope="module")
+def pack() -> ModuleType:
+ """Absence is explicit optional-parser coverage loss, never parser acceptance."""
+ pytest.importorskip("tree_sitter_language_pack", reason="QML-00 needs the optional pinned language-pack candidate")
+ return importlib.import_module("tree_sitter_language_pack")
+
+
+def parse(pack: ModuleType, filename: str):
+ source = (FIXTURES / filename).read_bytes()
+ grammar = "qmldir" if filename.startswith("qmldir") else "qmljs"
+ return source, pack.get_parser(grammar).parse(source).root_node
+
+
+def test_qml00_candidate_distribution_fingerprint(pack):
+ """Candidate version changes require a new recorded parser decision."""
+ assert version("tree-sitter-language-pack") == "0.11.0"
+ major, minor, patch = map(int, version("tree-sitter").split("."))
+ assert (0, 25, 2) <= (major, minor, patch) < (0, 26, 0)
+
+
+@pytest.mark.parametrize("grammar,source,root_type", [
+ ("qmljs", b"import QtQuick\nItem {}\n", "program"),
+ ("qmldir", b"module Example\nMain 1.0 Main.qml\n", "module_definition"),
+])
+def test_qml00_binding_uses_graphify_tree_sitter_api(pack, grammar, source, root_type):
+ """Installed grammar capsules work with current Graphify API/ABI primitives."""
+ language = Language(pack.get_binding(grammar))
+ assert MIN_COMPATIBLE_LANGUAGE_VERSION <= language.abi_version <= LANGUAGE_VERSION
+ root = Parser(language).parse(source).root_node
+ assert root.type == root_type and not root.has_error
+ assert root.end_byte == len(source)
+
+
+@pytest.mark.parametrize("filename,counts", [
+ ("Main.qml", {"ui_import": 4, "ui_object_definition": 2, "ui_property": 5, "ui_signal": 2, "function_declaration": 1}),
+ ("Modern68.qml", {"ui_pragma": 1, "ui_inline_component": 1, "enum_declaration": 1, "arrow_function": 1}),
+ ("Grouped.qml", {"ui_object_definition": 3}),
+ ("Script.qml", {"function_declaration": 1, "arrow_function": 1, "ui_signal": 0, "ui_import": 1}),
+ ("UnicodeCRLF.qml", {"ui_property": 2, "function_declaration": 1}),
+ ("Imports.qml", {"ui_import": 4}),
+ ("plugins.qmltypes", {"ui_object_definition": 6}),
+])
+def test_qml00_supported_syntax_corpus(pack, filename, counts):
+ """Hand-counted declarations and syntax nodes survive representative inputs."""
+ _, root = parse(pack, filename)
+ assert root.type == "program" and not root.has_error
+ for kind, expected in counts.items():
+ assert len(nodes(root, kind)) == expected, (filename, kind)
+
+
+@pytest.mark.parametrize("filename,expected", [
+ ("Main.qml", [("QtQuick", None, None), ("QtQuick.Controls", "6.5", "Controls"), ('"./components"', None, "Local"), ('"helpers.js"', None, "Helpers")]),
+ ("Imports.qml", [("QtQuick", "6", None), ("Example.Widgets", "1.2", None), ("Example.Services", None, "Services"), ('"./components"', None, None)]),
+])
+def test_qml00_import_source_version_alias_are_distinct_fields(pack, filename, expected):
+ """Qualified and quoted sources retain their version/alias without resolution."""
+ _, root = parse(pack, filename)
+ actual = [tuple(field_text(node, name) for name in ("source", "version", "alias")) for node in nodes(root, "ui_import")]
+ assert actual == expected
+
+
+def test_qml00_properties_signals_methods_and_nested_object_ownership(pack):
+ """Modifiers, aliases, typed signatures and nested IDs keep AST ownership."""
+ _, root = parse(pack, "Main.qml")
+ properties = {field_text(node, "name"): node for node in nodes(root, "ui_property")}
+ assert {name: field_text(node, "type") for name, node in properties.items()} == {
+ "title": "string", "doubled": "int", "extras": "list", "count": "int", "editorText": "alias",
+ }
+ for name, modifier in (("title", "required"), ("doubled", "readonly"), ("extras", "default")):
+ assert [text(node) for node in nodes(properties[name], "ui_property_modifier")] == [modifier]
+ assert field_text(properties["editorText"], "value") == "editor.text"
+ parameters = nodes(root, "ui_signal_parameter")
+ assert [(field_text(node, "name"), field_text(node, "type")) for node in parameters] == [("value", "int"), ("label", "string"), ("value", "int")]
+ method = nodes(root, "function_declaration")[0]
+ assert field_text(method, "name") == "bump" and field_text(method, "return_type") == ": int"
+ owners = {}
+ for obj in nodes(root, "ui_object_definition"):
+ initializer = obj.child_by_field_name("initializer")
+ assert initializer is not None
+ owners[field_text(obj, "type_name")] = [field_text(node, "value") for node in initializer.named_children if node.type == "ui_binding" and field_text(node, "name") == "id"]
+ assert owners == {"Item": ["root"], "Controls.TextField": ["editor"]}
+
+
+def test_qml00_inline_component_keeps_local_definition_and_base_type(pack):
+ """Inline type names and their own alias owner must not be flattened."""
+ _, root = parse(pack, "Modern68.qml")
+ component = nodes(root, "ui_inline_component")[0]
+ assert field_text(component, "name") == "Tile"
+ definition = component.child_by_field_name("component")
+ assert definition is not None
+ assert field_text(definition, "type_name") == "Rectangle"
+ assert [field_text(node, "name") for node in nodes(definition, "ui_property")] == ["value", "ownValue"]
+ assert field_text(nodes(definition, "ui_property")[1], "value") == "tile.value"
+ binding_names = [field_text(node, "name") for node in nodes(root, "ui_binding")]
+ assert all(name is not None for name in binding_names)
+ assert [name for name in binding_names if name is not None and "." in name] == ["Component.onCompleted"]
+
+
+def test_qml00_grouped_property_object_definition_pitfall_is_explicit(pack):
+ """Pinned grammar treats grouped properties as objects; semantic handling is pending."""
+ _, root = parse(pack, "Grouped.qml")
+ definitions = nodes(root, "ui_object_definition")
+ assert [field_text(node, "type_name") for node in definitions] == ["Text", "anchors", "font"]
+ assert field_text(definitions[1], "initializer") == "{ left: parent.left; topMargin: 4 }"
+
+
+def test_qml00_embedded_js_keeps_scopes_and_ignores_literal_declarations(pack):
+ """Shadowed variables stay in separate blocks; strings/comments invent no signals."""
+ _, root = parse(pack, "Script.qml")
+ variables = [node for node in nodes(root, "variable_declarator") if field_text(node, "name") == "value"]
+ assert len(variables) == 2
+ blocks = []
+ for variable in variables:
+ assert variable.parent is not None
+ block = variable.parent.parent
+ assert block is not None and block.type == "statement_block"
+ blocks.append(block)
+ assert blocks[0] != blocks[1]
+ assert [field_text(node, "function") for node in nodes(root, "call_expression")] == ["transform", "evaluate"]
+ assert not nodes(root, "ui_signal")
+ assert any("signal fake" in text(node) for node in nodes(root, "string"))
+
+
+@pytest.mark.parametrize("filename", VALID_QML + ("Incomplete.qml", "Malformed.qml", "Empty.qml", "qmldir", "qmldir.modern"))
+def test_qml00_source_ranges_use_original_utf8_bytes(pack, filename):
+ """All node ranges/points remain bounded in the original, unnormalized source."""
+ source, root = parse(pack, filename)
+ assert root.end_byte == len(source)
+ for node in walk(root):
+ assert 0 <= node.start_byte <= node.end_byte <= len(source)
+ assert tuple(node.start_point) == point_at(source, node.start_byte)
+ assert tuple(node.end_point) == point_at(source, node.end_byte)
+ source[node.start_byte:node.end_byte].decode("utf-8")
+
+
+def test_qml00_unicode_crlf_columns_are_byte_offsets(pack):
+ """A non-ASCII prefix changes byte columns without changing CRLF row counts."""
+ source, root = parse(pack, "UnicodeCRLF.qml")
+ assert source.count(b"\r\n") == source.count(b"\n") > 0
+ properties = nodes(root, "ui_property")
+ assert [field_text(node, "name") for node in properties] == ["café", "après"]
+ second_name = properties[1].child_by_field_name("name")
+ assert second_name is not None
+ assert tuple(second_name.start_point)[0] == 4
+ prefix = source.split(b"\r\n")[4][:second_name.start_point.column]
+ assert second_name.start_point.column == len(prefix) > len(prefix.decode("utf-8"))
+ assert text(nodes(root, "function_declaration")[0].child_by_field_name("name")) == "résumé"
+
+
+@pytest.mark.parametrize("filename", ["Incomplete.qml", "Malformed.qml", "Empty.qml"])
+def test_qml00_malformed_empty_partial_inputs_do_not_look_successful(pack, filename):
+ """Error status survives recovery, including an ERROR root in an editor buffer."""
+ source, root = parse(pack, filename)
+ assert root.has_error
+ assert any(node.is_error or node.is_missing for node in walk(root))
+ if filename != "Empty.qml":
+ retained = [node for node in nodes(root, "ui_property") if field_text(node, "name") == "retained"]
+ assert len(retained) == 1
+ assert source[retained[0].start_byte:retained[0].end_byte].startswith(b"property ")
+ else:
+ assert source == b"" and root.end_byte == 0
+
+
+@pytest.mark.parametrize("filename,expected", [
+ ("qmldir", ["module Example.Widgets", "Main 1.0 Main.qml", "singleton Theme 1.0 Theme.qml", "internal Helper Helper.qml", "typeinfo plugins.qmltypes", "plugin exampleplugin", "classname ExamplePlugin", "depends QtQuick.Controls 6.5"]),
+ ("qmldir.modern", ["module Example.Widgets", "optional plugin exampleplugin", "import QtQuick auto", "prefer :/qt/qml/Example/Widgets/", "Main Main.qml"]),
+])
+def test_qml00_qmldir_preserves_basic_and_modern_command_tokens(pack, filename, expected):
+ """Module-descriptor tokens remain available for a future directive validator."""
+ _, root = parse(pack, filename)
+ assert root.type == "module_definition" and not root.has_error
+ # qmldir command spans include the line terminator; compare its raw contents
+ # independently of checkout line endings, while range tests retain all bytes.
+ assert [text(node).rstrip("\r\n") for node in nodes(root, "command")] == expected
+
+
+@pytest.mark.parametrize("source", [b"module\n", b"Main wrong Main.qml\n", b"nonsense albatross unknown\n"])
+def test_qml00_qmldir_missing_semantic_validation_is_explicit(pack, source):
+ """Characterize rejected-by-contract metadata accepted by the generic grammar."""
+ root = pack.get_parser("qmldir").parse(source).root_node
+ assert not root.has_error
+ assert len(nodes(root, "command")) == 1
+
+
+def test_qml00_qmldir_invalid_tokens_report_error(pack):
+ """Lexically invalid metadata is distinct from permissive directive syntax."""
+ root = pack.get_parser("qmldir").parse(b"@@@\n").root_node
+ assert root.has_error and nodes(root, "ERROR")
+
+
+def test_qml00_unavailable_grammar_has_explicit_failure(pack):
+ """Missing native grammar must raise a bounded library error, not empty success."""
+ with pytest.raises(LookupError, match="Could not find language library"):
+ pack.get_parser("qml_probe_missing_grammar")
+
+
+def test_qml00_fresh_isolated_subprocess_needs_no_network_or_corpus_execution(pack, tmp_path):
+ """Cold installed invocation works with no repository imports or download cache."""
+ inherited = {key: value for key, value in os.environ.items() if key.upper() in {"PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP"}}
+ for key in ("HOME", "USERPROFILE", "XDG_CACHE_HOME", "APPDATA", "LOCALAPPDATA"):
+ inherited[key] = str(tmp_path)
+ child = subprocess.run([sys.executable, "-I", "-c", OFFLINE_PROBE, str(FIXTURES.resolve())], cwd=tmp_path, env=inherited, capture_output=True, text=True, encoding="utf-8", timeout=30)
+ assert child.returncode == 0, child.stderr
+ result = json.loads(child.stdout)
+ assert result["network_denied"] is True and result["unavailable"] == "LookupError"
+ assert set(result["results"]) == {"qmljs", "qmldir"}
+ for grammar in result["results"].values():
+ assert grammar["has_error"] is False and grammar["end_byte"] == grammar["size"] > 0
diff --git a/tests/test_qml_platform_matrix.py b/tests/test_qml_platform_matrix.py
new file mode 100644
index 0000000000..d199ac2feb
--- /dev/null
+++ b/tests/test_qml_platform_matrix.py
@@ -0,0 +1,71 @@
+"""Keep the declared artifact lanes and clean-install source boundaries honest."""
+from __future__ import annotations
+
+import ast
+from itertools import product
+from pathlib import Path
+import re
+from types import SimpleNamespace
+
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def test_advertised_twelve_lanes_equal_actual_wheel_workflow():
+ workflow = (ROOT / ".github/workflows/qml-wheel.yml").read_text(encoding="utf-8")
+ os_values = re.search(r"^\s+os: \[(.*?)\]$", workflow, re.MULTILINE)
+ python_values = re.search(r"^\s+python: \[(.*?)\]$", workflow, re.MULTILINE)
+ assert os_values is not None and python_values is not None
+ operating_systems = [value.strip() for value in os_values.group(1).split(",")]
+ interpreters = ast.literal_eval("[" + python_values.group(1) + "]")
+ actual = set(product(operating_systems, interpreters))
+ document = (ROOT / "docs/qt-qml/PLATFORM_MATRIX.md").read_text(encoding="utf-8")
+ declared = set(re.findall(r"^\| ((?:ubuntu|windows|macos)-latest) \| (3\.\d+) \|", document, re.MULTILINE))
+ assert len(actual) == len(declared) == 12
+ assert declared == actual
+
+
+def test_wheel_runner_keeps_optional_core_and_isolated_offline_smoke_boundaries(tmp_path, monkeypatch):
+ # Characterize the shipped CI runner rather than replace it with a mock CLI.
+ runner = (ROOT / "tests/qml_ci.py").read_text(encoding="utf-8")
+ smoke = (ROOT / "tests/qml_installed_smoke.py").read_text(encoding="utf-8")
+ tree = ast.parse(runner)
+ assert not any(isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
+ and node.func.attr in {"skip", "xfail"} for node in ast.walk(tree))
+ assert 'str(wheel) + "[qml,watch]"' in runner
+ assert 'interpreter(core), str(wheel)' in runner
+ assert 'interpreter(extra), "-I", smoke' in runner
+ assert 'interpreter(core), "-I", smoke, "--core-only"' in runner
+ assert '"GRAPHIFY_QML_TEST_WHEEL"' in runner or "GRAPHIFY_QML_TEST_WHEEL=" in runner
+ # Execute only the trusted runner's selection expression against real files.
+ # This preserves native joint-case admission and Linux artifact isolation
+ # without invoking builds, installs or the runner's subprocess boundary.
+ selection = next(node.value for node in ast.walk(tree)
+ if isinstance(node, ast.Assign)
+ and any(isinstance(target, ast.Name) and target.id == "tests"
+ for target in node.targets))
+ expression = compile(ast.Expression(selection), "qml_ci.py selection", "eval")
+ corpus = tmp_path / "tests"
+ corpus.mkdir()
+ selected = ["test_qml_wheel_artifact.py", "test_qml_contract.py",
+ "test_qt_contract.py", "test_upstream_qt_contract.py"]
+ for name in [*reversed(selected), "test_python_contract.py", "qml_ci.py",
+ "test_upstream_other.py", "test_qt_contract.txt"]:
+ (corpus / name).write_text("", encoding="utf-8")
+ # A real filesystem may already enumerate alphabetically. Reverse only its
+ # actual fixture entries so the production sort must establish stable order.
+ original_glob = Path.glob
+ def unsorted_glob(self, pattern):
+ entries = original_glob(self, pattern)
+ if self == corpus and pattern == "test_*.py":
+ return iter(sorted(entries, key=lambda path: path.name, reverse=True))
+ return entries
+ monkeypatch.setattr(Path, "glob", unsorted_glob)
+ for arguments, expected in [([], sorted(f"tests/{name}" for name in selected)),
+ (["--artifact-only"], ["tests/test_qml_wheel_artifact.py"])]:
+ actual = eval(expression, {"root": tmp_path, "sys": SimpleNamespace(argv=arguments)})
+ assert [Path(path).as_posix() for path in actual] == expected
+ assert '"socket.connect"' in smoke and '"subprocess.Popen"' in smoke
+ assert "sys.addaudithook(offline_guard)" in smoke
+ assert "QML_PARSER_MISSING" in smoke
+ assert "validate_extraction(batch)" in smoke and "build_from_json(batch" in smoke
diff --git a/tests/test_qml_read_failure.py b/tests/test_qml_read_failure.py
new file mode 100644
index 0000000000..5d882ff1bd
--- /dev/null
+++ b/tests/test_qml_read_failure.py
@@ -0,0 +1,49 @@
+"""A real source-disappearance read failure cannot commit a partial QML graph."""
+import pytest
+
+import graphify.__main__ as mainmod
+import graphify.extract as ex
+from graphify.watch import _rebuild_code
+from tests.qml_persistence_helpers import healthy_qml, make_project, protected_snapshot
+
+
+@pytest.mark.parametrize("operation", ["extract", "update"])
+def test_qml_read_race_preserves_prior_products_then_recovers(tmp_path, monkeypatch, operation):
+ """QML-001/002: OS read failure passes through real extraction and persistence gates."""
+ project = make_project(tmp_path)
+ source = project / "Main.qml"
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _rebuild_code(project, no_cluster=True, acquire_lock=False)
+ out = project / "graphify-out"
+ # A prior full build may also own presentation products. The integrity gate
+ # must return before any of those products or the root marker can advance.
+ for name in ("graph.html", "GRAPH_REPORT.md", ".graphify_labels.json", "callflow.html"):
+ (out / name).write_text("previous complete output\n", encoding="utf-8")
+ before = protected_snapshot(out)
+ source.write_text("Item { property int changed: 4 }\n", encoding="utf-8")
+
+ def source_disappears(path):
+ # Simulate a filesystem race after discovery, with an actual failed read.
+ path.unlink()
+ path.read_bytes()
+
+ monkeypatch.setitem(ex._DISPATCH, ".qml", source_disappears)
+ if operation == "extract":
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(mainmod.sys, "argv", [
+ "graphify", "extract", str(project), "--code-only", "--no-cluster",
+ "--force", "--allow-partial",
+ ])
+ with pytest.raises(SystemExit) as info:
+ mainmod.main()
+ assert info.value.code == 1
+ else:
+ assert not _rebuild_code(project, changed_paths=[source], no_cluster=True,
+ force=True, acquire_lock=False)
+ assert not source.exists()
+ assert protected_snapshot(out) == before
+
+ # Restoring the source and parser recovers without deleting graph/cache data.
+ source.write_text("Item { property int changed: 5 }\n", encoding="utf-8")
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False)
diff --git a/tests/test_qml_resolution.py b/tests/test_qml_resolution.py
new file mode 100644
index 0000000000..c4c3182379
--- /dev/null
+++ b/tests/test_qml_resolution.py
@@ -0,0 +1,190 @@
+"""QML-004 production lookup/projection acceptance and corpus-boundary negatives."""
+
+import copy
+import json
+from pathlib import Path
+
+import pytest
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.qml_resolution import build_qml_index, resolve_qml_project
+from tests.qml_resolution_helpers import component_key, index_for, module, named
+
+
+def test_aliased_directory_and_uri_imports_do_not_cross_bind(tmp_path):
+ """AC01/02: same-label exports remain distinct under document-local aliases."""
+ sources = {**module("Public.One"), **module("Public.Two"),
+ "local/Button.qml": "QtObject {}",
+ "Main.qml": 'import Public.One 1.0 as One\nimport Public.Two 1.0 as Two\nimport "local" as Local\nOne.Button {}',
+ "Other.qml": "QtObject {}"}
+ index, _, nodes, _ = index_for(tmp_path, sources)
+ scope = component_key(nodes)
+ for qualified, target in [("One.Button", "Public/One/Button.qml"),
+ ("Two.Button", "Public/Two/Button.qml"), ("Local.Button", "local/Button.qml")]:
+ result = index.resolve_type("Main.qml", scope, qualified)
+ assert result.target_id == named(nodes, target, "component")["id"]
+ assert result.evidence
+ assert index.resolve_type("Main.qml", scope, "Button").status == "unavailable"
+ assert index.resolve_type("Other.qml", component_key(nodes, "Other.qml"), "One.Button").status == "unavailable"
+
+
+def test_version_availability_and_latest_compatible_export(tmp_path):
+ """AC01/02/03: available module version precedes per-type introduction lookup."""
+ sources = {"Public/Tools/qmldir": "module Public.Tools\nButton 1.0 Old.qml\nButton 1.1 New.qml\nWindow 1.2 Window.qml",
+ "Public/Tools/Old.qml": "QtObject {}", "Public/Tools/New.qml": "QtObject {}",
+ "Public/Tools/Window.qml": "QtObject {}", "Main.qml": "QtObject {}"}
+ index, _, nodes, _ = index_for(tmp_path, sources)
+ for major, minor, target in [(1, 0, "Old.qml"), (1, 2, "New.qml"), (1, None, "New.qml"), (None, None, "New.qml")]:
+ result = index.module_type("Public.Tools", major, minor, "Button", importer="Main.qml")
+ assert result.target_id == named(nodes, "Public/Tools/" + target, "component")["id"]
+ for major, minor in [(1, 3), (2, 0)]:
+ result = index.module_type("Public.Tools", major, minor, "Button", importer="Main.qml")
+ assert result.status == "unavailable"
+ assert result.reason == "module_version_unavailable"
+
+
+def test_competing_providers_and_missing_modules_have_no_target_edges(tmp_path):
+ """AC03: source-owned unresolved sites survive, arbitrary targets never do."""
+ sources = {**module("Public.One"), **module("Public.Two"),
+ "Main.qml": "import Public.One 1.0\nimport Public.Two 1.0\nimport Missing.Api 1.0\nButton {}"}
+ _, per_file, nodes, edges = index_for(tmp_path, sources)
+ resolve_qml_project(per_file, nodes, edges, root=tmp_path)
+ site = named(nodes, "Main.qml", "type_use")
+ assert qml_metadata(site)["status"] == "ambiguous"
+ assert len(qml_metadata(site)["candidates"]) == 2
+ assert not any(edge["source"] == site["id"] and edge["relation"] == "uses" for edge in edges)
+ missing = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and
+ node["label"] == "Missing.Api"][0]
+ assert qml_metadata(missing)["reason"] == "module_not_in_import_roots"
+ assert not any(edge["source"] == missing["id"] and edge["relation"] == "imports" for edge in edges)
+
+
+def test_declared_roots_and_remote_import_never_expand_corpus(tmp_path, monkeypatch):
+ """AC04: accepted facts are the complete source set, independent of disk files."""
+ sources = {"src/Public/Tools/qmldir": "module Public.Tools\nButton 1.0 Button.qml",
+ "src/Public/Tools/Button.qml": "QtObject {}",
+ "unrelated/Button.qml": "QtObject {}",
+ "Main.qml": 'import Public.Tools 1.0\nimport "https://invalid.example/source" as Remote\nButton {}'}
+ index, per_file, nodes, edges = index_for(tmp_path, sources)
+ scope = component_key(nodes)
+ assert index.resolve_type("Main.qml", scope, "Button").status == "unavailable"
+ index = build_qml_index(nodes, edges, root=tmp_path, import_roots=["src", "../outside"])
+ assert index.resolve_type("Main.qml", scope, "Button").target_id == named(nodes, "src/Public/Tools/Button.qml", "component")["id"]
+ def forbidden(*args, **kwargs):
+ raise AssertionError("Resolver attempted source expansion")
+ monkeypatch.setattr(Path, "read_text", forbidden)
+ monkeypatch.setattr(Path, "read_bytes", forbidden)
+ import socket
+ monkeypatch.setattr(socket, "create_connection", forbidden)
+ resolve_qml_project(per_file, nodes, edges, root=tmp_path, import_roots=["src"])
+ remote = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and
+ node["label"].startswith("https:")][0]
+ assert qml_metadata(remote)["reason"] == "import_outside_corpus"
+
+
+def test_projection_is_repeatable_owned_and_json_portable(tmp_path):
+ """Derived sites preserve roles without changing borrowed declarations/context."""
+ _, per_file, nodes, edges = index_for(tmp_path, {**module("Public.One"),
+ "Main.qml": "import Public.One 1.0\nButton {}"})
+ before = copy.deepcopy(nodes)
+ fresh = {path: value for path, value in per_file.items() if path.name == "Main.qml"}
+ resolve_qml_project(fresh, nodes, edges, root=tmp_path)
+ assert nodes[:len(before)] == before
+ count = (len(nodes), len(edges))
+ resolve_qml_project(fresh, nodes, edges, root=tmp_path)
+ assert (len(nodes), len(edges)) == count
+ payload = json.loads(json.dumps({"nodes": nodes, "edges": edges}))
+ index = build_qml_index(payload["nodes"], payload["edges"], root=tmp_path / "relocated")
+ assert index.resolve_type("Main.qml", component_key(nodes), "Button").status == "resolved"
+ endpoints = {node["id"] for node in nodes}
+ assert all(edge["source"] in endpoints and edge["target"] in endpoints for edge in edges)
+ assert all(qml_metadata(node).get("kind") not in {"type_use", "import_resolution"} or
+ node["source_file"] == "Main.qml" for node in nodes)
+
+
+def test_module_import_auto_visibility_and_dependency_non_visibility(tmp_path):
+ """Imported module exports enter the namespace; packaging depends does not."""
+ sources = {**module("Public.Base"), "Public/Facade/qmldir": "module Public.Facade\nMarker 1.0 Marker.qml\nimport Public.Base auto",
+ "Public/Facade/Marker.qml": "QtObject {}", "Public/OnlyDepends/qmldir":
+ "module Public.OnlyDepends\nMarker 1.0 Marker.qml\ndepends Public.Base 1.0",
+ "Public/OnlyDepends/Marker.qml": "QtObject {}", "Main.qml": "QtObject {}"}
+ index, _, nodes, _ = index_for(tmp_path, sources)
+ expected = named(nodes, "Public/Base/Button.qml", "component")["id"]
+ assert index.module_type("Public.Facade", 1, 0, "Button", importer="Main.qml").target_id == expected
+ assert index.module_type("Public.OnlyDepends", 1, 0, "Button", importer="Main.qml").status == "unavailable"
+
+
+def test_versioned_layout_and_missing_version_evidence(tmp_path):
+ """Declared versioned roots and exact module-version evidence govern selection."""
+ sources = {"Public/Tools.1/qmldir": "module Public.Tools\nButton 1.0 Major.qml",
+ "Public/Tools.1/Major.qml": "QtObject {}", "Public/Tools.1.0/qmldir":
+ "module Public.Tools\nButton 1.0 Exact.qml", "Public/Tools.1.0/Exact.qml": "QtObject {}",
+ "Public/Unversioned/qmldir": "module Public.Unversioned\nButton Button.qml",
+ "Public/Unversioned/Button.qml": "QtObject {}", "Main.qml": "QtObject {}"}
+ index, _, nodes, _ = index_for(tmp_path, sources)
+ assert index.module_type("Public.Tools", 1, 0, "Button", importer="Main.qml").target_id == named(nodes, "Public/Tools.1.0/Exact.qml", "component")["id"]
+ assert index.module_type("Public.Unversioned", 1, 0, "Button", importer="Main.qml").reason == "module_version_unavailable"
+ assert index.module_type("Public.Unversioned", None, None, "Button", importer="Main.qml").status == "resolved"
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_projection_graph_build_export_reload_preserves_all_site_roles(tmp_path, directed):
+ """Independent import/type sites survive the actual simple graph and JSON export."""
+ from graphify.build import build_from_json
+ from graphify.export import to_json
+ from graphify.paths import load_node_link_graph
+ _, per_file, nodes, edges = index_for(tmp_path, {**module("Public.One"),
+ "Main.qml": "import Public.One 1.0\nButton { Button {} }"})
+ resolve_qml_project(per_file, nodes, edges, root=tmp_path)
+ graph = build_from_json({"nodes": nodes, "edges": edges}, directed=directed, root=tmp_path)
+ def mechanism(source, target, relation):
+ endpoints = (source, target) if directed else tuple(sorted((source, target)))
+ return endpoints, relation
+ expected = {mechanism(edge["source"], edge["target"], edge["relation"]) for edge in edges}
+ assert {mechanism(source, target, data["relation"]) for source, target, data in graph.edges(data=True)} == expected
+ target = tmp_path / "roundtrip.json"
+ assert to_json(graph, {}, str(target))
+ restored = load_node_link_graph(json.loads(target.read_text()))
+ assert {mechanism(source, target, data["relation"]) for source, target, data in restored.edges(data=True)} == expected
+ assert len([nid for nid in restored if qml_metadata(restored.nodes[nid]).get("kind") == "type_use" and
+ restored.nodes[nid]["source_file"] == "Main.qml"]) == 2
+
+
+def test_directory_and_script_projection_and_ignored_disk_provider(tmp_path):
+ """Import roles select accepted namespaces/files; ignored disk data stays invisible."""
+ sources = {"local/Widget.qml": "QtObject {}", "helpers.js": "function answer() { return 42; }",
+ "Main.qml": 'import "local" as Local\nimport "helpers.js" as Helpers\nimport Ignored.Api 1.0\nLocal.Widget {}'}
+ _, per_file, nodes, edges = index_for(tmp_path, sources)
+ excluded = tmp_path / "Ignored/Api"
+ excluded.mkdir(parents=True)
+ (excluded / "qmldir").write_text("module Ignored.Api\nWidget 1.0 Widget.qml")
+ (excluded / "Widget.qml").write_text("QtObject {}")
+ resolve_qml_project(per_file, nodes, edges, root=tmp_path)
+ for literal, target_kind in [("local", "directory_namespace"), ("helpers.js", None)]:
+ site = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and node["label"] == literal][0]
+ resolved = [edge["target"] for edge in edges if edge["source"] == site["id"] and edge["relation"] == "imports"]
+ assert len(resolved) == 1
+ target = next(node for node in nodes if node["id"] == resolved[0])
+ assert qml_metadata(target).get("kind") == target_kind
+ ignored = [node for node in nodes if qml_metadata(node).get("kind") == "import_resolution" and node["label"] == "Ignored.Api"][0]
+ assert qml_metadata(ignored)["status"] == "unavailable"
+
+
+def test_recursive_module_queries_are_bounded_and_resource_prefer_is_explicit(tmp_path, monkeypatch):
+ """Metadata cycles, branch limits and resource redirection never guess providers."""
+ sources = {**module("Public.Base"), "Public/Facade/qmldir": "module Public.Facade\nMarker 1.0 Marker.qml\nimport Public.Base auto",
+ "Public/Facade/Marker.qml": "QtObject {}", "Public/Resources/qmldir":
+ "module Public.Resources\nWidget 1.0 Widget.qml\nprefer :/resources/", "Public/Resources/Widget.qml": "QtObject {}"}
+ index, _, _, _ = index_for(tmp_path, sources)
+ assert index.module_type("Public.Resources", 1, 0, "Widget", importer="Main.qml").reason == "preferred_path_requires_resource_index"
+ monkeypatch.setattr("graphify.qml_module_index.MAX_MODULE_RESOLUTION_STEPS", 1)
+ assert index.module_type("Public.Facade", 1, 0, "Button", importer="Main.qml").reason == "module_resolution_work_limit"
+
+
+def test_module_script_exports_have_separate_lookup_roles(tmp_path):
+ """A script namespace cannot satisfy an object type use with the same spelling."""
+ sources = {"Public/Tools/qmldir": "module Public.Tools\nHelpers 1.0 helpers.js",
+ "Public/Tools/helpers.js": "function answer() { return 42; }", "Main.qml": "QtObject {}"}
+ index, _, _, _ = index_for(tmp_path, sources)
+ assert index.module_type("Public.Tools", 1, 0, "Helpers", importer="Main.qml").status == "unavailable"
+ assert index.module_script("Public.Tools", 1, 0, "Helpers", importer="Main.qml").status == "resolved"
diff --git a/tests/test_qml_resolver_safety.py b/tests/test_qml_resolver_safety.py
new file mode 100644
index 0000000000..0abda61da7
--- /dev/null
+++ b/tests/test_qml_resolver_safety.py
@@ -0,0 +1,42 @@
+"""Production join failures must not publish partial Qt/QML graphs."""
+from pathlib import Path
+
+import pytest
+
+from graphify.extract import extract
+from graphify.qml_safety import QmlSafetyError, require_complete_qml
+
+
+def test_native_join_failure_is_guarded_and_successful_retry_is_clean(tmp_path, monkeypatch):
+ import graphify.qml_resolution as resolver
+ path = tmp_path / "Main.qml"
+ path.write_text("Item { property int value: 3 }", encoding="utf-8")
+ real = resolver.resolve_qml_project
+
+ def broken(*args, **kwargs):
+ args[1].append({"id": "partial_join_must_not_escape"})
+ raise RuntimeError("private backend failure details")
+
+ monkeypatch.setattr(resolver, "resolve_qml_project", broken)
+ failed = extract([path], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert failed["qml_failures"][0]["code"] == "QML_RESOLUTION_FAILED"
+ assert not any(n["id"] == "partial_join_must_not_escape" for n in failed["nodes"])
+ assert "private backend" not in str(failed)
+ with pytest.raises(QmlSafetyError, match="QML_GRAPH_PRESERVED"):
+ require_complete_qml(failed, [path], root=tmp_path, operation="test")
+ monkeypatch.setattr(resolver, "resolve_qml_project", real)
+ retried = extract([path], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not retried["qml_failures"] and not retried["failed_sources"]
+ require_complete_qml(retried, [path], root=tmp_path, operation="retry")
+
+
+def test_nested_object_scope_keys_have_fixed_transport_size(tmp_path):
+ from graphify.extractors.qml import extract_qml
+ path = tmp_path / "Nested.qml"
+ path.write_text("Item {" * 30 + "property int value: 1" + "}" * 30, encoding="utf-8")
+ result = extract_qml(path, root=tmp_path)
+ assert not result.get("error")
+ scopes = [n["metadata"]["qml"]["object_scope_key"] for n in result["nodes"]
+ if n["metadata"]["qml"]["kind"] == "object"]
+ assert len(scopes) == 30 and len(set(scopes)) == 30
+ assert {len(scope) for scope in scopes} == {64}
diff --git a/tests/test_qml_safety.py b/tests/test_qml_safety.py
new file mode 100644
index 0000000000..6e6231397b
--- /dev/null
+++ b/tests/test_qml_safety.py
@@ -0,0 +1,73 @@
+"""QML-001/002/011 integrity gates inspect source ownership, not graph counts."""
+import pytest
+
+from graphify.qml_safety import (
+ QmlSafetyError, is_qml_path, qml_refresh_required, require_complete_qml,
+ require_qml_watch_root,
+)
+
+
+@pytest.mark.parametrize("name", ["Main.qml", "MAIN.QML", "types.qmltypes", "qmldir"])
+def test_qml_source_metadata_recognition(name):
+ assert is_qml_path(name)
+
+
+@pytest.mark.parametrize("name", ["qmldir.txt", "QMLDIR", "app.py", "qml"])
+def test_qml_named_metadata_does_not_admit_unrelated_files(name):
+ assert not is_qml_path(name)
+
+
+@pytest.mark.parametrize("marker", ["error", "partial", "parse_errors", "failed_sources", "qml_failures"])
+def test_guard_rejects_unsafe_qml_with_surviving_nodes(tmp_path, marker):
+ """Recovered nodes cannot make a failed component scope authoritative."""
+ path = tmp_path / "Main.qml"
+ result = {"nodes": [{"id": "kept", "source_file": "Main.qml"}], "edges": []}
+ value = True
+ if marker == "failed_sources":
+ value = [str(path)]
+ elif marker == "qml_failures":
+ value = [{"code": "QML_PARSE_PARTIAL", "source_file": str(path)}]
+ result[marker] = value
+ with pytest.raises(QmlSafetyError, match="QML_GRAPH_PRESERVED"):
+ require_complete_qml(result, [path], operation="extract", root=tmp_path)
+
+
+def test_omitted_qml_contribution_cannot_be_reported_complete(tmp_path):
+ with pytest.raises(QmlSafetyError, match="Main.qml"):
+ require_complete_qml({"nodes": []}, [tmp_path / "Main.qml"], operation="extract", root=tmp_path)
+
+
+def test_clean_qml_and_unrelated_failure_keep_separate_contracts(tmp_path):
+ """An existing Python error is outside the QML publication policy."""
+ result = {"nodes": [{"source_file": "Main.qml"}], "failed_sources": ["bad.py"]}
+ require_complete_qml(result, [tmp_path / "Main.qml"], operation="extract", root=tmp_path)
+ require_complete_qml({"nodes": [], "error": "old behavior"}, ["bad.py"], operation="extract")
+
+
+def test_diagnostic_escapes_delimiters_and_does_not_print_foreign_root(tmp_path):
+ """Failure evidence contains a bounded escaped filename, never raw source."""
+ source = tmp_path / 'hidden' / 'bad\n".qml'
+ with pytest.raises(QmlSafetyError) as info:
+ require_complete_qml({"nodes": [], "failed_sources": [str(source)]}, [source], operation="extract")
+ message = str(info.value)
+ assert str(tmp_path) not in message
+ assert '\\n' in message and '\\"' in message
+ assert "\n" not in message
+
+
+@pytest.mark.parametrize("changed", [["Main.qml"], ["qmldir"], ["types.qmltypes"], ["helpers.js"]])
+def test_qml_dependencies_widen_to_conservative_refresh(changed):
+ assert qml_refresh_required(["Main.qml", "keep.py"], changed)
+
+
+def test_non_qml_and_deleted_final_component_refresh_contracts():
+ assert not qml_refresh_required(["keep.py"], ["helpers.js"])
+ assert not qml_refresh_required(["Main.qml"], ["keep.py"])
+ assert qml_refresh_required(["keep.py"], ["deleted.qml"])
+
+
+def test_qml_subfolder_watch_rejects_before_rebasing(tmp_path):
+ with pytest.raises(QmlSafetyError, match="QML_ROOT_MISMATCH"):
+ require_qml_watch_root(["Main.qml"], project_root=tmp_path, watch_root=tmp_path / "ui")
+ require_qml_watch_root(["keep.py"], project_root=tmp_path, watch_root=tmp_path / "src")
+ require_qml_watch_root(["Main.qml"], project_root=tmp_path, watch_root=tmp_path)
diff --git a/tests/test_qml_scope.py b/tests/test_qml_scope.py
new file mode 100644
index 0000000000..68e2b47bf5
--- /dev/null
+++ b/tests/test_qml_scope.py
@@ -0,0 +1,129 @@
+"""QML-005 component IDs, shadowing, singletons and source-only member lookup."""
+
+import copy
+
+from graphify.extractors.qml_facts import encode_metadata, qml_metadata
+from graphify.qml_resolution import build_qml_index
+from tests.qml_resolution_helpers import component_key, index_for, named
+
+
+def _member(index, nodes, file, receiver, expression, *, lexical_names=()):
+ owner = named(nodes, file, "object", receiver)
+ md = qml_metadata(owner)
+ return index.resolve_member(file, md["component_key"], md["object_scope_key"], expression,
+ lexical_names=lexical_names)
+
+
+def test_component_ids_do_not_leak_between_files_or_inline_components(tmp_path):
+ """AC01: equal ID spellings are component-local, including nested definitions."""
+ sources = {"Main.qml": """QtObject {
+ id: root
+ property int value: 1
+ component Local: QtObject { id: root; property int value: 2 }
+ Local { id: child }
+ }""", "Other.qml": "QtObject { id: root; property int value: 3; QtObject { id: foreign } }"}
+ index, _, nodes, _ = index_for(tmp_path, sources)
+ outer = [node for node in nodes if
+ node["source_file"] == "Main.qml" and qml_metadata(node).get("kind") == "property" and
+ qml_metadata(node).get("component_key") == component_key(nodes)][0]
+ assert _member(index, nodes, "Main.qml", "child", "root.value").target_id == outer["id"]
+ inner_root = [node for node in nodes if node["source_file"] == "Main.qml" and
+ qml_metadata(node).get("kind") == "object" and
+ qml_metadata(node).get("component_key") != component_key(nodes)][0]
+ md = qml_metadata(inner_root)
+ inner = index.resolve_member("Main.qml", md["component_key"], md["object_scope_key"], "root.value")
+ assert inner.target_id != outer["id"]
+ assert qml_metadata(index.nodes[inner.target_id])["component_key"] == md["component_key"]
+ assert _member(index, nodes, "Main.qml", "child", "foreign").status == "unavailable"
+
+
+def test_inline_shadow_and_inherited_members_are_distinct_roles(tmp_path):
+ """AC02/04: local inline names shadow imported types; instances inherit members."""
+ sources = {"Public/Tools/qmldir": "module Public.Tools\nBase 1.0 Base.qml",
+ "Public/Tools/Base.qml": "QtObject { property int inheritedValue: 1 }",
+ "Main.qml": """import Public.Tools 1.0
+ QtObject { id: root; property int Base: 7
+ component Base: QtObject { property int inlineValue: 2 }
+ Base { id: local }
+ }""",
+ "Inherited.qml": "import Public.Tools 1.0\nBase { id: derived }"}
+ index, _, nodes, _ = index_for(tmp_path, sources)
+ inline = named(nodes, "Main.qml", "inline_component", "Base")
+ assert index.resolve_type("Main.qml", component_key(nodes), "Base").target_id == inline["id"]
+ assert _member(index, nodes, "Main.qml", "local", "local.inlineValue").target_id == named(nodes, "Main.qml", "property", "inlineValue")["id"]
+ inherited = _member(index, nodes, "Inherited.qml", "derived", "inheritedValue")
+ assert inherited.target_id == named(nodes, "Public/Tools/Base.qml", "property", "inheritedValue")["id"]
+ same_label = _member(index, nodes, "Main.qml", "root", "Base")
+ assert same_label.target_id == named(nodes, "Main.qml", "property", "Base")["id"]
+ assert len({inline["id"], same_label.target_id, named(nodes, "Main.qml", "object", "local")["id"]}) == 3
+
+
+def test_singleton_pragma_and_qualified_access(tmp_path):
+ """AC02: singleton metadata must agree with source pragma before member use."""
+ sources = {"Public/Tools/qmldir": "module Public.Tools\nsingleton State 1.0 State.qml\nBroken 1.0 Broken.qml",
+ "Public/Tools/State.qml": "pragma Singleton\nQtObject { property int value: 1 }",
+ "Public/Tools/Broken.qml": "QtObject { property int value: 2 }",
+ "Main.qml": "import Public.Tools 1.0 as Tools\nQtObject { id: root }"}
+ index, _, nodes, _ = index_for(tmp_path, sources)
+ result = _member(index, nodes, "Main.qml", "root", "Tools.State.value")
+ assert result.target_id == named(nodes, "Public/Tools/State.qml", "property", "value")["id"]
+ assert _member(index, nodes, "Main.qml", "root", "Tools.Broken.value").status != "resolved"
+ bad_nodes = copy.deepcopy(nodes)
+ # A provider's singleton export without a matching source pragma is rejected.
+ state = named(nodes, "Public/Tools/State.qml", "component")
+ for node in bad_nodes:
+ if node["id"] == state["id"]:
+ node["metadata"]["qml"] = encode_metadata({**qml_metadata(node), "singleton": False})
+ bad_index = build_qml_index(bad_nodes, [], root=tmp_path)
+ assert bad_index.module_type("Public.Tools", 1, 0, "State", importer="Main.qml").reason == "singleton_pragma_missing"
+
+
+def test_internal_external_dynamic_and_lexical_members_stay_unresolved(tmp_path):
+ """AC03/04: visibility and unknown runtime context cannot become label matches."""
+ sources = {"Public/Tools/qmldir": "module Public.Tools\nPublic 1.0 Public.qml\ninternal Hidden Hidden.qml",
+ "Public/Tools/Public.qml": "QtObject { property int value: 1 }",
+ "Public/Tools/Hidden.qml": "QtObject {}",
+ "Main.qml": "import Public.Tools 1.0\nQtObject { id: root; property int value: 2 }",
+ "Unrelated.qml": "QtObject { property int runtimeValue: 3 }"}
+ index, _, nodes, edges = index_for(tmp_path, sources)
+ assert index.resolve_type("Main.qml", component_key(nodes), "Hidden").reason == "internal_export"
+ assert _member(index, nodes, "Main.qml", "root", "runtimeValue").status == "unavailable"
+ assert _member(index, nodes, "Main.qml", "root", "dynamicLookup.value").status == "unavailable"
+ lexical = _member(index, nodes, "Main.qml", "root", "root.value", lexical_names=("root",))
+ assert lexical.status == "dynamic" and lexical.reason == "javascript_lexical_binding"
+ private_nodes = copy.deepcopy(nodes)
+ value = named(nodes, "Main.qml", "property", "value")
+ for node in private_nodes:
+ if node["id"] == value["id"]:
+ node["metadata"]["qml"] = encode_metadata({**qml_metadata(node), "internal": True})
+ private = build_qml_index(private_nodes, edges, root=tmp_path)
+ assert _member(private, private_nodes, "Main.qml", "root", "value").reason == "member_not_visible"
+
+
+def test_inheritance_cycles_are_bounded_and_do_not_select_unrelated_members(tmp_path):
+ """Recursive source types give a coverage reason rather than recursion or guessing."""
+ index, _, nodes, _ = index_for(tmp_path, {"A.qml": "B { id: a }", "B.qml": "A {}",
+ "Other.qml": "QtObject { property int missing: 1 }"})
+ result = _member(index, nodes, "A.qml", "a", "missing")
+ assert result.status == "unsupported"
+ assert result.reason == "inheritance_cycle_or_limit"
+
+
+def test_typed_properties_this_and_explicit_static_parent(tmp_path):
+ """Continuation reuses scoped member APIs; lexical ownership alone is no parent proof."""
+ index, _, nodes, edges = index_for(tmp_path, {"Value.qml": "QtObject { property int count: 1 }",
+ "Main.qml": "QtObject { id: root; property int rootValue: 2; property Value value; QtObject { id: child } }"})
+ expected = named(nodes, "Value.qml", "property", "count")["id"]
+ assert _member(index, nodes, "Main.qml", "child", "value.count").target_id == expected
+ value = named(nodes, "Main.qml", "property", "value")
+ assert index.follow_member(value["id"], ["count"]).target_id == expected
+ assert _member(index, nodes, "Main.qml", "root", "this.rootValue").status == "resolved"
+ assert _member(index, nodes, "Main.qml", "child", "parent.rootValue").reason == "runtime_parent_unestablished"
+ static_nodes = copy.deepcopy(nodes)
+ child = named(nodes, "Main.qml", "object", "child")
+ for node in static_nodes:
+ if node["id"] == child["id"]:
+ node["metadata"]["qml"] = encode_metadata({**qml_metadata(node),
+ "static_parent_scope_key": qml_metadata(node)["parent_scope_key"]})
+ static = build_qml_index(static_nodes, edges, root=tmp_path)
+ assert _member(static, static_nodes, "Main.qml", "child", "parent.rootValue").target_id == named(nodes, "Main.qml", "property", "rootValue")["id"]
diff --git a/tests/test_qml_script_persistence.py b/tests/test_qml_script_persistence.py
new file mode 100644
index 0000000000..a9ec8eaff1
--- /dev/null
+++ b/tests/test_qml_script_persistence.py
@@ -0,0 +1,44 @@
+"""Actual imported JS failure cannot replace the prior completed graph/manifest."""
+
+import pytest
+
+from graphify.watch import _rebuild_code
+from tests.qml_persistence_helpers import protected_snapshot
+from tests.test_qml_cli_persistence import _run
+
+
+@pytest.mark.parametrize("operation", ["cli", "watch"])
+@pytest.mark.parametrize("failure", ["syntax", "read"])
+def test_imported_script_failure_preserves_products_and_recovers(tmp_path, monkeypatch, operation, failure):
+ """QML-007/011: real source parsing/read failure wins over force/partial flags."""
+ project = tmp_path / "project"
+ project.mkdir()
+ qml = project / "Main.qml"
+ script = project / "helper.js"
+ qml.write_text('import "helper.js" as H\nQtObject { property int value: H.answer() }', encoding="utf-8")
+ script.write_text("function answer() { return 1; }", encoding="utf-8")
+ def run():
+ if operation == "cli":
+ return _run(monkeypatch, project, flags=("--force", "--allow-partial")) == 0
+ return _rebuild_code(project, changed_paths=[script], no_cluster=True, force=True, acquire_lock=False)
+ assert run()
+ before = protected_snapshot(project / "graphify-out")
+ assert {"graph.json", "manifest.json"}.issubset(before)
+ if failure == "syntax":
+ script.write_text("function answer( {", encoding="utf-8")
+ assert not run()
+ else:
+ import graphify.extractors.qml_scripts as scripts
+ parse = scripts.parse_script
+ def disappear(path):
+ # Deterministic race after generic JS extraction but before the
+ # QML-owned overlay: the actual source read now fails on disk.
+ path.unlink()
+ return parse(path)
+ with monkeypatch.context() as race:
+ race.setattr(scripts, "parse_script", disappear)
+ assert not run()
+ assert protected_snapshot(project / "graphify-out") == before
+ script.write_text("function answer() { return 2; }", encoding="utf-8")
+ assert run()
+ assert "manifest.json" in protected_snapshot(project / "graphify-out")
diff --git a/tests/test_qml_scripts.py b/tests/test_qml_scripts.py
new file mode 100644
index 0000000000..0d6438fd41
--- /dev/null
+++ b/tests/test_qml_scripts.py
@@ -0,0 +1,136 @@
+"""QML-007-AC02/04 accepted script aliases, modules and shared-source isolation."""
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.extract import extract
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_scripts import collect_qml_scripts
+from tests.qml_expression_helpers import extraction, nodes, single, target_edges
+
+
+def test_literal_imported_helpers_and_library_do_not_inherit_document_ids(tmp_path):
+ result = extraction(tmp_path, {"helper.js": ".pragma library\nfunction helper(n) { return n; }\nfunction unavailable() { return root.value; }",
+ "Main.qml": 'import "helper.js" as H\nQtObject { id: root; property int value: H.helper(2); function run() { H.unavailable(); } }',
+ "Other.qml": 'import "helper.js" as Different\nQtObject { id: root; property int value: Different.helper(3) }'})
+ helper = single(result, "qml_script_function", "helper")
+ for name in ("H.helper", "Different.helper"):
+ assert target_edges(result, single(result, "call", name), "qml_script_call")[0]["target"] == helper["id"]
+ script_read = single(result, "read", "root.value", "helper.js")
+ assert qml_metadata(script_read)["status"] == "unavailable"
+ assert qml_metadata(script_read)["reason"] == "script_runtime_context_unavailable"
+ assert not target_edges(result, script_read)
+ graph = build_from_json(result, directed=True, root=tmp_path)
+ for site in nodes(result, "call", "H.helper"):
+ assert graph.has_edge(site["id"], helper["id"])
+ assert single(result, "script_file")["label"].startswith("QML script: ")
+
+
+def test_mjs_explicit_exports_aliases_and_private_functions(tmp_path):
+ result = extraction(tmp_path, {"helper.mjs": "function hidden() { return 1; }\nfunction local() { return hidden(); }\nexport { local as publicName };\nexport const arrow = (n) => local();",
+ "Main.qml": 'import "helper.mjs" as H\nQtObject { property int good: H.publicName(); property int arrow: H.arrow(1); property int bad: H.hidden() }'})
+ assert target_edges(result, single(result, "call", "H.publicName"))[0]["target"] == single(result, "qml_script_function", "local")["id"]
+ assert target_edges(result, single(result, "call", "H.arrow"))[0]["target"] == single(result, "qml_script_function", "arrow")["id"]
+ assert not target_edges(result, single(result, "call", "H.hidden"))
+ assert qml_metadata(single(result, "call", "H.hidden"))["status"] == "unavailable"
+ hidden = single(result, "call", "hidden", "helper.mjs")
+ assert target_edges(result, hidden)[0]["target"] == single(result, "qml_script_function", "hidden")["id"]
+
+
+def test_qmldir_exported_script_namespaces_keep_module_qualifiers(tmp_path):
+ result = extraction(tmp_path, {"Public/Tools/qmldir": "module Public.Tools\nHelper 1.0 helper.js",
+ "Public/Tools/helper.js": ".pragma library\nfunction calculate() { return 2; }",
+ "Main.qml": "import Public.Tools 1.0 as Tools\nQtObject { property int value: Tools.Helper.calculate() }"})
+ call = single(result, "call", "Tools.Helper.calculate")
+ assert target_edges(result, call)[0]["target"] == single(result, "qml_script_function", "calculate")["id"]
+ assert qml_metadata(call)["evidence"]
+
+
+def test_accepted_script_dependencies_support_classic_and_esm_imports(tmp_path):
+ result = extraction(tmp_path, {"dep.js": "function number() { return 1; }",
+ "main.js": '.import "dep.js" as D\nfunction number() { return D.number(); }',
+ "dep.mjs": "export function answer() { return 2; }",
+ "main.mjs": "import {answer as renamed} from './dep.mjs'; export function number() { return renamed(); }",
+ "Main.qml": 'import "main.js" as C\nimport "main.mjs" as E\nQtObject { property int value: C.number() + E.number() }'})
+ assert target_edges(result, single(result, "call", "D.number", "main.js"))[0]["target"] == single(result, "qml_script_function", "number", "dep.js")["id"]
+ assert target_edges(result, single(result, "call", "renamed", "main.mjs"))[0]["target"] == single(result, "qml_script_function", "answer", "dep.mjs")["id"]
+
+
+def test_qt_directive_masking_preserves_original_utf8_crlf_expression_spans(tmp_path):
+ source = "// café\r\n.pragma library\r\nfunction helper() { return unavailable; }\r\n"
+ result = extraction(tmp_path, {"helper.js": source,
+ "Main.qml": 'import "helper.js" as H\nQtObject { property int value: H.helper() }'})
+ script_read = single(result, "read", "unavailable", "helper.js")
+ span = qml_metadata(script_read)["span"]
+ raw = (tmp_path / "helper.js").read_bytes()
+ assert raw[span["start_byte"]:span["end_byte"]] == b"unavailable"
+ assert span["start_row"] == 2
+ assert script_read["source_location"] == "L3-L3"
+
+
+def test_qt_directive_text_inside_comments_and_templates_is_not_active(tmp_path):
+ source = '/*\n.pragma library\n.import "missing.js" as Wrong\n*/\nconst example = `\n.pragma library\n`;\nfunction helper() { return 1; }'
+ result = extraction(tmp_path, {"helper.js": source,
+ "Main.qml": 'import "helper.js" as H\nQtObject { property int value: H.helper() }'})
+ assert qml_metadata(single(result, "script_file"))["pragma_library"] is False
+ assert not nodes(result, "script_import")
+ assert target_edges(result, single(result, "call", "H.helper"))
+
+
+def test_script_overlay_retains_original_shared_js_nodes_and_never_executes(tmp_path):
+ marker = tmp_path / "must-not-exist"
+ script = tmp_path / "shared.js"
+ script.write_text(f"function shared() {{ return 1; }}\nrequire('fs').writeFileSync('{marker.as_posix()}', 'bad');", encoding="utf-8")
+ qml = tmp_path / "Main.qml"
+ qml.write_text('import "shared.js" as H\nQtObject { property int value: H.shared() }', encoding="utf-8")
+ baseline = extract([script], root=tmp_path, cache_root=tmp_path, parallel=False)
+ mixed = extract([script, qml], root=tmp_path, cache_root=tmp_path, parallel=False)
+ generic = [node for node in mixed["nodes"] if node["source_file"] == "shared.js" and not qml_metadata(node)]
+ assert generic == baseline["nodes"]
+ assert not marker.exists()
+ assert all(node["label"].startswith("QML script: ") for node in nodes(mixed, "qml_script_function"))
+
+
+def test_generic_js_calls_cannot_bind_to_qml_owned_expression_sites(tmp_path):
+ """A matching occurrence label cannot become a shared JavaScript definition."""
+ shared, web, qml = (tmp_path / name for name in ("shared.js", "web.js", "Main.qml"))
+ shared.write_text("function invoke() { phantom(); }", encoding="utf-8")
+ web.write_text("function web() { phantom(); }", encoding="utf-8")
+ qml.write_text('import "shared.js" as H\nQtObject { property int value: H.invoke() }', encoding="utf-8")
+ baseline = extract([shared, web], root=tmp_path, cache_root=tmp_path, parallel=False)
+ mixed = extract([shared, web, qml], root=tmp_path, cache_root=tmp_path, parallel=False)
+ generic_ids = {node["id"] for node in mixed["nodes"] if not qml_metadata(node)}
+ generic_edges = [edge for edge in mixed["edges"] if edge["source"] in generic_ids]
+ assert generic_edges == baseline["edges"]
+ assert all(edge["target"] in generic_ids for edge in generic_edges)
+
+
+def test_script_overlay_does_not_read_unaccepted_imports_or_network(tmp_path, monkeypatch):
+ qml = tmp_path / "Main.qml"
+ qml.write_text('import "outside.js" as H\nQtObject { property int value: H.run() }', encoding="utf-8")
+ outside = tmp_path / "outside.js"
+ outside.write_text("function run() {}", encoding="utf-8")
+ from graphify.extractors.qml import extract_qml
+ result = extract_qml(qml, root=tmp_path)
+ before = copy.deepcopy(result)
+ def forbidden(*args, **kwargs):
+ raise AssertionError("script overlay expanded accepted corpus")
+ monkeypatch.setattr(type(outside), "open", forbidden)
+ collect_qml_scripts([qml], [result], root=tmp_path)
+ assert result == before
+
+
+@pytest.mark.parametrize("source,code", [("function broken( {", "QML_SCRIPT_SYNTAX"),
+ (".pragma library\nexport function f() {}", "QML_SCRIPT_UNSUPPORTED")])
+def test_imported_partial_or_unsupported_module_failure_is_explicit(tmp_path, source, code):
+ script = tmp_path / "bad.mjs"
+ script.write_text(source, encoding="utf-8")
+ qml = tmp_path / "Main.qml"
+ qml.write_text('import "bad.mjs" as H\nQtObject { property int value: H.f() }', encoding="utf-8")
+ result = extract([qml, script], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert result["qml_failures"] == [{"code": code, "source_file": "bad.mjs"}]
+ assert not nodes(result, "qml_script_function")
+ json.dumps(result)
diff --git a/tests/test_qml_skillgen_guidance.py b/tests/test_qml_skillgen_guidance.py
new file mode 100644
index 0000000000..c35daf2465
--- /dev/null
+++ b/tests/test_qml_skillgen_guidance.py
@@ -0,0 +1,170 @@
+"""Test shipped assistant runbooks at rendering and publication boundaries."""
+from __future__ import annotations
+
+import json
+import re
+import pytest
+
+from tools.skillgen import gen
+
+
+@pytest.mark.parametrize("key", ["aider", "devin"])
+def test_qt_guidance_preserves_frozen_monolith_guard(key):
+ platform = gen.load_platforms()[key]
+ assert gen.monolith_roundtrip(platform) == []
+ content = gen.render(platform)[0].content
+ assert "from graphify.qml_safety import require_complete_qml" in content
+ assert "stop this manual merge procedure" in content
+
+
+@pytest.mark.parametrize("key", ["aider", "devin"])
+@pytest.mark.parametrize("drift", [
+ "[Qt/QML] Silently force every failed extraction.",
+ "[Qt/QML] Install a mandatory Qt SDK before analysis.",
+ " dangerous_custom_hook()",
+])
+def test_qt_marker_does_not_sanction_unrelated_monolith_edits(monkeypatch, key, drift):
+ platform = gen.load_platforms()[key]
+ original = gen.render
+
+ def changed(candidate):
+ artifacts = original(candidate)
+ return [gen.RenderedArtifact(a.path, a.content + drift + "\n") for a in artifacts]
+
+ monkeypatch.setattr(gen, "render", changed)
+ problems = gen.monolith_roundtrip(platform)
+ assert any(drift.strip() in problem for problem in problems), problems
+
+
+def _ast_script(content):
+ section = content.split("#### Part A - Structural extraction for code files", 1)[1]
+ block = re.search(r"```bash\n(.*?)\n```", section, re.DOTALL)
+ assert block is not None
+ python = block.group(1).split('-c "\n', 1)[1].rsplit('\n"', 1)[0]
+ return python.replace('\\"', '"')
+
+
+@pytest.mark.parametrize("key", ["claude", "aider", "devin"])
+@pytest.mark.parametrize("failure", [False, True])
+def test_rendered_ast_uses_trusted_root_and_gates_publication(tmp_path, monkeypatch, key, failure):
+ import graphify.extract as extraction
+ from graphify.qml_safety import QmlSafetyError
+
+ out = tmp_path / "graphify-out"
+ out.mkdir()
+ root = tmp_path / "accepted 'source $ with ; symbols"
+ root.mkdir()
+ source = root / "Main.qml"
+ source.write_text("import QtQuick\nItem {}", encoding="utf-8")
+ (out / ".graphify_root").write_text(str(root), encoding="utf-8")
+ detect = ".graphify_detect.json"
+ ast_output = tmp_path / ".graphify_ast.json" if key == "aider" else out / ".graphify_ast.json"
+ detect_output = tmp_path / detect if key == "aider" else out / detect
+ detect_output.write_text(json.dumps({"files": {"code": [str(source)]}}), encoding="utf-8")
+ calls = []
+
+ def fake_extract(paths, **kwargs):
+ calls.append((paths, kwargs))
+ return {"nodes": [{"source_file": "Main.qml"}], "edges": [],
+ "qml_failures": [{"source_file": "Main.qml"}] if failure else []}
+
+ monkeypatch.setattr(extraction, "extract", fake_extract)
+ monkeypatch.chdir(tmp_path)
+ content = gen.render(gen.load_platforms()[key])[0].content
+ if failure:
+ with pytest.raises(QmlSafetyError, match="QML_GRAPH_PRESERVED"):
+ exec(_ast_script(content), {})
+ assert not ast_output.exists()
+ else:
+ exec(_ast_script(content), {})
+ assert json.loads(ast_output.read_text(encoding="utf-8"))["nodes"]
+ assert calls == [([source], {"root": root.resolve(), "cache_root": root.resolve(),
+ "qml_import_roots": (".",), "refresh_native": True})]
+ assert not (out / ".qt_analysis.json").exists()
+
+
+def test_all_rendered_pipeline_hosts_receive_qt_safety_and_scoped_id_guidance():
+ artifacts = gen.render_all(gen.load_platforms())
+ bodies = [a for a in artifacts if "#### Part A - Structural extraction" in a.content]
+ assert bodies
+ for artifact in bodies:
+ assert "graphifyy[qml]" in artifact.content, artifact.path
+ assert "require_complete_qml(result, code_files" in artifact.content, artifact.path
+ assert "an emission does not prove a synchronous slot call" in artifact.content
+ references = [a for a in artifacts if a.path.endswith("/extraction-spec.md")]
+ assert references
+ for artifact in references:
+ assert "Reuse existing Qt/QML AST IDs verbatim" in artifact.content, artifact.path
+ assert "source-backed AST bridge" in artifact.content, artifact.path
+ updates = [a for a in artifacts if a.path.endswith("/update.md")]
+ assert updates
+ assert all("stop this manual merge procedure" in a.content for a in updates)
+
+
+def _resolved_provider(nodes, edges):
+ from graphify.extractors.qml_facts import qml_metadata
+
+ sites = [node for node in nodes if node.get("source_file") == "Main.qml"
+ and qml_metadata(node).get("kind") == "type_use"]
+ assert len(sites) == 1
+ assert qml_metadata(sites[0])["status"] == "resolved"
+ targets = [edge["target"] for edge in edges
+ if edge["source"] == sites[0]["id"] and edge.get("relation") == "uses"]
+ assert len(targets) == 1
+ return next(node["source_file"] for node in nodes if node["id"] == targets[0])
+
+
+@pytest.mark.parametrize("key", ["claude", "aider", "devin"])
+@pytest.mark.parametrize("roots", [("first", "second"), ("second", "first")])
+def test_rendered_ast_configured_provider_matches_production_update(tmp_path, monkeypatch, key, roots):
+ from graphify.extract import collect_files
+ from graphify.paths import load_node_link_graph
+ from graphify.watch import _rebuild_code
+
+ root = tmp_path / "project"
+ root.mkdir()
+ (root / "Main.qml").write_text("import Public.Tools 1.0\nService {}\n", encoding="utf-8")
+ for directory, value in (("first", 1), ("second", 2)):
+ module = root / directory / "Public/Tools"
+ module.mkdir(parents=True)
+ (module / "qmldir").write_text("module Public.Tools\nService 1.0 Service.qml\n", encoding="utf-8")
+ (module / "Service.qml").write_text(f"QtObject {{ property int value: {value} }}", encoding="utf-8")
+ accepted = collect_files(root)
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", json.dumps(roots))
+ out = tmp_path / "graphify-out"
+ out.mkdir()
+ (out / ".graphify_root").write_text(str(root), encoding="utf-8")
+ detect = tmp_path / ".graphify_detect.json" if key == "aider" else out / ".graphify_detect.json"
+ detect.write_text(json.dumps({"files": {"code": [str(path) for path in accepted]}}), encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ exec(_ast_script(gen.render(gen.load_platforms()[key])[0].content), {})
+ ast = tmp_path / ".graphify_ast.json" if key == "aider" else out / ".graphify_ast.json"
+ result = json.loads(ast.read_text(encoding="utf-8"))
+ expected = f"{roots[0]}/Public/Tools/Service.qml"
+ assert _resolved_provider(result["nodes"], result["edges"]) == expected
+ assert not (out / ".qt_analysis.json").exists(), "AST-only inspection must not commit a graph checkpoint"
+ assert _rebuild_code(root, changed_paths=None, no_cluster=True, acquire_lock=False)
+ graph = load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8")))
+ nodes = [dict(data, id=identity) for identity, data in graph.nodes(data=True)]
+ edges = [dict(data, source=data.get("_src", source), target=data.get("_tgt", target))
+ for source, target, data in graph.edges(data=True)]
+ assert _resolved_provider(nodes, edges) == expected
+
+
+@pytest.mark.parametrize("key", ["claude", "aider", "devin"])
+def test_rendered_ast_invalid_configuration_preserves_prior_publication(tmp_path, monkeypatch, key):
+ source = tmp_path / "Main.qml"
+ source.write_text("Item {}", encoding="utf-8")
+ out = tmp_path / "graphify-out"
+ out.mkdir()
+ (out / ".graphify_root").write_text(str(tmp_path), encoding="utf-8")
+ detect = tmp_path / ".graphify_detect.json" if key == "aider" else out / ".graphify_detect.json"
+ detect.write_text(json.dumps({"files": {"code": [str(source)]}}), encoding="utf-8")
+ ast = tmp_path / ".graphify_ast.json" if key == "aider" else out / ".graphify_ast.json"
+ ast.write_bytes(b"prior accepted AST\n")
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["../outside"]')
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(ValueError, match="QT_CONFIG"):
+ exec(_ast_script(gen.render(gen.load_platforms()[key])[0].content), {})
+ assert ast.read_bytes() == b"prior accepted AST\n"
+ assert not (out / ".qt_analysis.json").exists()
diff --git a/tests/test_qml_syntax_profile.py b/tests/test_qml_syntax_profile.py
new file mode 100644
index 0000000000..57eeaaadb7
--- /dev/null
+++ b/tests/test_qml_syntax_profile.py
@@ -0,0 +1,192 @@
+"""Hand-checked production QML syntax, object ownership and template boundaries."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+
+import pytest
+
+from graphify.extract import extract
+from graphify.extractors.qml import extract_qml
+from graphify.extractors.qml_facts import qml_metadata
+from tests.qml_test_helpers import assert_contains, assert_spans, by_kind, canonical, one, write_qml
+
+FIXTURES = Path(__file__).parent / "fixtures/qml/parser_probe"
+PROFILE = {
+ "Main.qml": {"component": 1, "object": 2, "property": 5, "signal": 2, "function": 1, "import": 4},
+ "Grouped.qml": {"component": 1, "object": 1, "property_group": 2, "import": 1},
+ "Imports.qml": {"component": 1, "object": 1, "import": 4},
+ "Modern68.qml": {"component": 1, "inline_component": 1, "object": 3, "property": 3, "enum": 1, "import": 1},
+ "Script.qml": {"component": 1, "object": 1, "property": 1, "function": 1, "import": 1},
+ "UnicodeCRLF.qml": {"component": 1, "object": 1, "property": 2, "function": 1, "import": 1},
+}
+DECLARATIONS = {"component", "inline_component", "object", "property_group", "property", "signal", "function", "enum", "import"}
+
+
+def literal(result, source, kind, name, expected):
+ """A concrete original-source slice is independent of AST-to-fact conversion."""
+ node = one(result, kind, name)
+ span = qml_metadata(node)["span"]
+ encoded = expected.encode("utf-8")
+ assert span["start_byte"] == source.index(encoded)
+ assert span["end_byte"] == span["start_byte"] + len(encoded)
+ assert source[span["start_byte"]:span["end_byte"]] == encoded
+ return qml_metadata(node)
+
+
+@pytest.mark.parametrize("filename", PROFILE)
+def test_qml001_ac02_handchecked_profile_uses_production_extractor_and_original_spans(tmp_path, filename):
+ """The parser spike corpus now exercises shipping declarations, not parser smoke only."""
+ source = (FIXTURES / filename).read_bytes()
+ path = tmp_path / filename
+ path.write_bytes(source)
+ result = extract_qml(path, root=tmp_path)
+ assert not result.get("error") and not result.get("qml_failures")
+ counts = {kind: len(by_kind(result, kind)) for kind in DECLARATIONS if by_kind(result, kind)}
+ assert counts == PROFILE[filename]
+ assert_spans(result, source)
+ assert canonical(result) == canonical(extract_qml(path, root=tmp_path))
+ assert all(node["source_file"] == filename for node in result["nodes"])
+ if filename == "Main.qml":
+ assert literal(result, source, "property", "title", "required property string title")["modifiers"] == ["required"]
+ assert literal(result, source, "property", "extras", "default property list extras")["raw_type"] == "list"
+ assert literal(result, source, "signal", "changed", "signal changed(value: int)")["signature"] == "(value: int)"
+ assert qml_metadata(one(result, "function", "bump"))["return_type"] == ": int"
+ elif filename == "Grouped.qml":
+ literal(result, source, "property_group", "anchors", "anchors { left: parent.left; topMargin: 4 }")
+ assert [qml_metadata(node)["type_name"] for node in by_kind(result, "object")] == ["Text"]
+ elif filename == "Imports.qml":
+ imports = [qml_metadata(node) for node in by_kind(result, "import")]
+ assert [(md["value"], md["major"], md["minor"], md["qualifier"]) for md in imports] == [
+ ("QtQuick", 6, None, None), ("Example.Widgets", 1, 2, None),
+ ("Example.Services", None, None, "Services"), ("./components", None, None, None)]
+ literal(result, source, "import", "QtQuick", "import QtQuick 6")
+ elif filename == "Modern68.qml":
+ assert qml_metadata(one(result, "inline_component", "Tile"))["raw_name"] == "Tile"
+ literal(result, source, "enum", "Mode", "enum Mode { Idle = 0, Active = 1 }")
+ literal(result, source, "property", "ownValue", "property alias ownValue: tile.value")
+ elif filename == "Script.qml":
+ assert qml_metadata(one(result, "function", "evaluate"))["signature"] == "(input)"
+ assert not by_kind(result, "signal")
+ assert not any(qml_metadata(node).get("raw_name") in {"fake", "phantom", "Broken"} for node in result["nodes"])
+ else:
+ assert source.count(b"\r\n") == source.count(b"\n") > 0
+ literal(result, source, "property", "après", "property int après: 2")
+ literal(result, source, "function", "résumé", "function résumé(étape) { return café + étape; }")
+
+
+def test_qml003_ac01_property_binding_and_array_objects_keep_exact_owners(tmp_path):
+ """Each held instance has its own scope and its actual property/object owner."""
+ source = """Item {
+ id: root
+ property QtObject held: QtObject { id: held; property int value: 1 }
+ property list extras: [
+ QtObject { id: firstProperty; property int value: 2 },
+ QtObject { id: secondProperty; property int value: 3 }
+ ]
+ data: [
+ QtObject { id: firstBinding; property int value: 4 },
+ QtObject { id: secondBinding; property int value: 5 }
+ ]
+ property QtObject emptyFirst: QtObject {}
+ property QtObject emptySecond: QtObject {}
+}
+"""
+ result = extract_qml(write_qml(tmp_path, source=source), root=tmp_path)
+ assert not result.get("error")
+ objects = by_kind(result, "object")
+ assert len(objects) == 8 and len({node["id"] for node in objects}) == 8
+ assert len({qml_metadata(node)["object_scope_key"] for node in objects}) == 8
+ assert len({qml_metadata(node)["component_key"] for node in objects}) == 1
+ named = {qml_metadata(node)["object_id"]: node for node in objects if qml_metadata(node)["object_id"]}
+ assert set(named) == {"root", "held", "firstProperty", "secondProperty", "firstBinding", "secondBinding"}
+ assert_contains(result, one(result, "property", "held"), named["held"])
+ for name in ("firstProperty", "secondProperty"):
+ assert_contains(result, one(result, "property", "extras"), named[name])
+ for name in ("firstBinding", "secondBinding"):
+ assert_contains(result, named["root"], named[name])
+ for property_name in ("emptyFirst", "emptySecond"):
+ owner = one(result, "property", property_name)
+ children = [edge["target"] for edge in result["edges"] if edge["source"] == owner["id"] and edge["relation"] == "contains"]
+ assert len(children) == 1
+ assert next(node for node in objects if node["id"] == children[0])["id"] != named["held"]["id"]
+ assert_spans(result, source.encode())
+
+
+@pytest.mark.parametrize("root_type,template", [
+ ("Item", "property Component factory: Item { id: hidden; property int value: 1 }"),
+ ("ListView", "delegate: Item { id: hidden; property int value: 1 }"),
+ ("Loader", "sourceComponent: Item { id: hidden; property int value: 1 }"),
+ ("Item", "Component { id: factory; Item { id: hidden; property int value: 1 } }"),
+])
+def test_qml003_ac02_template_bodies_do_not_leak_ids_to_enclosing_component(tmp_path, root_type, template):
+ """Source-held templates get separate component scopes without assumed runtime context."""
+ source = f"{root_type} {{\n id: outer\n {template}\n function read() {{ return hidden.value; }}\n}}"
+ path = write_qml(tmp_path, source=source)
+ result = extract([path], root=tmp_path, cache_root=tmp_path / "cache", parallel=False)
+ assert not result.get("qml_failures")
+ objects = {qml_metadata(node)["object_id"]: node for node in by_kind(result, "object")}
+ assert qml_metadata(objects["hidden"])["component_key"] != qml_metadata(objects["outer"])["component_key"]
+ sites = [node for node in by_kind(result, "read") if qml_metadata(node).get("reference") == "hidden.value"]
+ assert len(sites) == 1 and qml_metadata(sites[0])["status"] != "resolved"
+ assert not any(edge["source"] == sites[0]["id"] and edge["relation"] == "uses" for edge in result["edges"])
+
+
+# Guard external boundaries in a fresh interpreter before loading Graphify. A
+# production-parser success with no marker proves no corpus expression was run;
+# the guards additionally reject Python network/process access during extraction.
+OFFLINE_PRODUCTION = r'''
+import json
+from importlib.metadata import version
+from pathlib import Path
+import os
+import socket
+import subprocess
+import sys
+
+checkout, corpus = map(Path, sys.argv[1:])
+sys.path.insert(0, str(checkout))
+
+def forbidden(*args, **kwargs):
+ raise RuntimeError("Production QML extraction attempted network or external execution")
+
+socket.socket.connect = forbidden
+socket.socket.connect_ex = forbidden
+socket.create_connection = forbidden
+socket.getaddrinfo = forbidden
+subprocess.Popen = forbidden
+os.system = forbidden
+from graphify.extractors.qml import extract_qml
+
+counts = {}
+for path in sorted(corpus.glob("*.qml")):
+ result = extract_qml(path, root=corpus)
+ assert not result.get("error"), result.get("diagnostics")
+ counts[path.name] = len(result["nodes"])
+print(json.dumps({"parser": version("tree-sitter-language-pack"), "files": counts}))
+'''
+
+
+def test_qml001_ac02_profile_runs_offline_in_fresh_production_process_without_corpus_execution(tmp_path):
+ """All declared fixtures work with empty caches; embedded JS side effects stay inert."""
+ corpus = tmp_path / "corpus"
+ corpus.mkdir()
+ for filename in PROFILE:
+ (corpus / filename).write_bytes((FIXTURES / filename).read_bytes())
+ marker = tmp_path / "must-not-exist"
+ write_qml(corpus, "Dangerous.qml", 'QtObject { property var effect: require("fs").writeFileSync(' + json.dumps(marker.as_posix()) + ', "bad") }')
+ clean_home, cwd = tmp_path / "clean-home", tmp_path / "empty-cwd"
+ clean_home.mkdir()
+ cwd.mkdir()
+ env = dict(os.environ, HOME=str(clean_home), USERPROFILE=str(clean_home), XDG_CACHE_HOME=str(clean_home), PYTHONUTF8="1")
+ child = subprocess.run([sys.executable, "-I", "-c", OFFLINE_PRODUCTION, str(Path(__file__).resolve().parent.parent), str(corpus)],
+ cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8", timeout=45)
+ assert child.returncode == 0, child.stderr
+ result = json.loads(child.stdout)
+ assert result["parser"] == "0.11.0"
+ assert set(result["files"]) == set(PROFILE) | {"Dangerous.qml"}
+ assert all(count > 0 for count in result["files"].values())
+ assert not marker.exists()
diff --git a/tests/test_qml_types_metadata.py b/tests/test_qml_types_metadata.py
new file mode 100644
index 0000000000..7c17ad7ca3
--- /dev/null
+++ b/tests/test_qml_types_metadata.py
@@ -0,0 +1,148 @@
+"""QML-009-AC02: generated tooling origins, members/flags and conflict retention."""
+import copy
+import json
+
+import pytest
+
+from graphify.extractors.qml import extract_qml
+from graphify.extractors.qml_cmake import parse_cmake
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_types import extract_qmltypes, parse_qmltypes
+from graphify.qt_project_index import QtProjectIndex
+from graphify.security import sanitize_metadata
+
+SOURCE = '''import QtQuick.tooling 1.2
+Module {
+ dependencies: ["Public.Core 1.0"]
+ Component {
+ name: "NativeMain"; prototype: "QObject"; exports: ["Public.Tools/Main 1.0"]
+ isSingleton: true; isCreatable: false; exportMetaObjectRevisions: [256]
+ Property { name: "status"; type: "int"; isReadonly: true; notify: "statusChanged" }
+ Signal { name: "statusChanged"; Parameter { name: "value"; type: "int" } }
+ Method { name: "run"; type: "bool"; Parameter { name: "text"; type: "QString" } }
+ Enum { name: "Mode"; isFlag: true; values: ["Idle", "Active"] }
+ }
+}'''
+
+
+def records(result, kind):
+ return [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == kind]
+
+
+@pytest.mark.parametrize("bom", ["", "\ufeff"])
+@pytest.mark.parametrize("newline", ["\n", "\r\n"])
+def test_qmltypes_members_flags_exports_original_byte_provenance(tmp_path, bom, newline):
+ source = bom + SOURCE.replace("NativeMain", "NativeÉcran").replace("\n", newline)
+ path = tmp_path / "plugins.qmltypes"
+ path.write_bytes(source.encode())
+ result = extract_qmltypes(path, root=tmp_path)
+ assert not result.get("error")
+ component = records(result, "qmltypes_component")[0]
+ assert (component["raw_name"], component["singleton"], component["creatable"]) == (
+ "NativeÉcran", True, False)
+ assert records(result, "qmltypes_export")[0]["uri"] == "Public.Tools"
+ assert [(md["member_kind"], md["raw_name"]) for md in records(result, "qmltypes_member")] == [
+ ("property", "status"), ("signal", "statusChanged"), ("method", "run"), ("enum", "Mode")]
+ assert [(md["raw_name"], md["raw_type"]) for md in records(result, "qmltypes_parameter")] == [
+ ("value", "int"), ("text", "QString")]
+ fields = records(result, "qmltypes_field")
+ assert any(md["field_name"] == "isFlag" and md["value"] is True for md in fields)
+ assert any(md["field_name"] == "values" and md["value"] == "Active" for md in fields)
+ raw = source.encode()
+ for node in result["nodes"]:
+ md = qml_metadata(node)
+ span = md["span"]
+ for end in ("start", "end"):
+ prefix = raw[:span[f"{end}_byte"]]
+ assert span[f"{end}_row"] == prefix.count(b"\n")
+ assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1
+ assert all(edge["confidence"] == "EXTRACTED" for edge in result["edges"])
+ payload = json.loads(json.dumps(result))
+ for item in payload["nodes"] + payload["edges"]:
+ item["metadata"] = sanitize_metadata(item["metadata"])
+ assert records(payload, "qmltypes_field") == fields
+
+
+def test_generated_and_source_declarations_keep_both_origins_and_conflict(tmp_path):
+ path = tmp_path / "Main.qml"
+ path.write_bytes(b"Item { property string status: 'live' }")
+ qml, generated = extract_qml(path, root=tmp_path), parse_qmltypes(SOURCE)
+ cmake = parse_cmake("qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml)")
+ nodes = qml["nodes"] + generated["nodes"] + cmake["nodes"]
+ before = copy.deepcopy(nodes)
+ index = QtProjectIndex(nodes, [], root=tmp_path)
+ assert nodes == before
+ assert index.diagnostics[0]["code"] == "QML_TYPES_CONFLICT"
+ resolved = index.resolve_component("Public.Tools", "Main")
+ assert qml_metadata(index.nodes[resolved.target_id])["kind"] == "component"
+ assert len(records(generated, "qmltypes_component")) == 1
+ assert all(qml_metadata(n).get("generated") is True for n in generated["nodes"]
+ if qml_metadata(n)["kind"] != "file")
+
+
+def test_generated_tooling_description_does_not_prove_runtime_type_availability(tmp_path):
+ result = parse_qmltypes(SOURCE)
+ index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path)
+ assert index.resolve_component("Public.Tools", "Main").target_id is None
+
+
+@pytest.mark.parametrize("source", [
+ "Module { Component { name: dynamicName } }",
+ "Module { Component { name: 'SingleQuoteOutsideJsonSubset' } }",
+ 'Module { Component { name: "Thing"; unknownField: true } }',
+ 'Module { Component { name: "Thing"; exports: ["Invalid"] } }',
+ 'Module { Component { name: "Thing"; Property { name: "p"; type: factory() } } }',
+ 'Module { Component { name: "Thing"; isSingleton: "true" } }',
+ 'Module { Component { name: 12 } }',
+ "Module { Component {", "Item {}", "Module {}\nModule {}",
+])
+def test_unsupported_or_computed_type_description_never_succeeds(source):
+ result = parse_qmltypes(source)
+ assert result["qml_failures"]
+ assert result["nodes"] == []
+
+
+def test_qmltypes_parser_absence_is_an_explicit_failure(monkeypatch):
+ from graphify.extractors.qml_ast import QmlInputError
+ def unavailable():
+ raise QmlInputError("QML_PARSER_MISSING", "parser absent")
+ monkeypatch.setattr("graphify.extractors.qml_types.load_parser", unavailable)
+ result = parse_qmltypes(SOURCE)
+ assert result["qml_failures"][0]["code"] == "QML_PARSER_MISSING"
+
+
+def test_qmltypes_cpp_member_conflict_preserves_authoritative_source(tmp_path):
+ from graphify.extractors.qt_cpp_facts import encode_qt
+ generated = parse_qmltypes(SOURCE)
+ source = {"id": "native-property", "source_file": "backend.h", "type": "property",
+ "metadata": {"qt": encode_qt({"kind": "property", "class_name": "NativeMain",
+ "raw_name": "status", "raw_type": "QString",
+ "notify": "otherSignal"})}}
+ nodes = generated["nodes"] + [source]
+ before = copy.deepcopy(nodes)
+ index = QtProjectIndex(nodes, generated["edges"], root=tmp_path)
+ assert any(d["reason"] == "generated_member_disagrees_with_source" and
+ d["source_id"] == source["id"] for d in index.diagnostics)
+ assert index.nodes[source["id"]] == before[-1]
+ assert nodes == before
+
+
+def test_more_than_fifty_generated_enum_values_survive_transport():
+ values = ", ".join(json.dumps(f"Value{i}") for i in range(60))
+ result = parse_qmltypes('Module { Component { name: "Thing"; Enum { name: "Flags"; '
+ 'values: [' + values + '] } } }')
+ assert not result.get("error")
+ payload = json.loads(json.dumps(result))
+ for node in payload["nodes"]:
+ node["metadata"] = sanitize_metadata(node["metadata"])
+ assert len([md for md in records(payload, "qmltypes_field") if md["field_name"] == "values"]) == 60
+
+
+def test_qt6_enum_literal_object_keeps_signed_values_without_evaluation():
+ source = 'Module { Component { name: "Thing"; Enum { name: "Mode"; '
+ source += 'values: { "Ready": 1, "Missing": -1 } } } }'
+ result = parse_qmltypes(source)
+ assert not result.get("error")
+ assert [(md["enum_key"], md["value"]) for md in records(result, "qmltypes_field")
+ if md["field_name"] == "values"] == [("Ready", 1), ("Missing", -1)]
+ assert parse_qmltypes(source.replace('"Missing": -1', '"Ready": 2'))["qml_failures"]
diff --git a/tests/test_qml_watch_persistence.py b/tests/test_qml_watch_persistence.py
new file mode 100644
index 0000000000..2b52a6c1a5
--- /dev/null
+++ b/tests/test_qml_watch_persistence.py
@@ -0,0 +1,95 @@
+"""Production update/watch QML retention and conservative refresh contracts."""
+import pytest
+
+import graphify.extract as ex
+from graphify.watch import _batch_needs_llm_flag, _batch_triggers_rebuild, _rebuild_code
+from tests.qml_persistence_helpers import failing_qml, healthy_qml, make_project, protected_snapshot
+
+
+@pytest.mark.parametrize("kind", ["missing", "incompatible", "read", "partial"])
+@pytest.mark.parametrize("no_cluster", [False, True])
+@pytest.mark.parametrize("extra_nodes", [0, 8], ids=["equal-count-edge-loss", "larger-candidate"])
+def test_watch_qml_failure_preserves_completed_products_with_force(
+ tmp_path, monkeypatch, capsys, kind, no_cluster, extra_nodes,
+):
+ """QML-001/002/011: real graph/manifest writes cannot bypass the QML gate."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _rebuild_code(project, no_cluster=True, acquire_lock=False)
+ before = protected_snapshot(project / "graphify-out")
+ monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml(kind, extra_nodes=extra_nodes))
+ source = project / "Main.qml"
+ source.write_text("Item { property int changed: 2 }\n", encoding="utf-8")
+
+ assert not _rebuild_code(project, changed_paths=[source], force=True,
+ no_cluster=no_cluster, acquire_lock=False)
+ assert protected_snapshot(project / "graphify-out") == before
+ assert "QML_GRAPH_PRESERVED" in capsys.readouterr().out
+
+
+def test_watch_first_failure_produces_no_graph_or_manifest(tmp_path, monkeypatch):
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("missing"))
+ assert not _rebuild_code(project, no_cluster=True, force=True, acquire_lock=False)
+ assert not (project / "graphify-out" / "graph.json").exists()
+ assert not (project / "graphify-out" / "manifest.json").exists()
+
+
+def test_watch_qml_recovery_after_failed_edit(tmp_path, monkeypatch):
+ """Recovery re-extracts the pending edit and leaves a complete manifest."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _rebuild_code(project, no_cluster=True, acquire_lock=False)
+ source = project / "Main.qml"
+ source.write_text("Item { property int changed: 3 }\n", encoding="utf-8")
+ monkeypatch.setitem(ex._DISPATCH, ".qml", failing_qml("partial"))
+ assert not _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False)
+ settled = protected_snapshot(project / "graphify-out")
+ assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False)
+ assert protected_snapshot(project / "graphify-out") == settled
+
+
+def test_relative_subfolder_qml_update_keeps_prior_products(tmp_path, monkeypatch, capsys):
+ """QML scope IDs cannot safely use the legacy path-only subfolder rebase."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _rebuild_code(project, no_cluster=True, acquire_lock=False)
+ before = protected_snapshot(project / "graphify-out")
+ monkeypatch.chdir(tmp_path)
+ assert not _rebuild_code(project.relative_to(tmp_path), force=True,
+ no_cluster=True, acquire_lock=False)
+ assert protected_snapshot(project / "graphify-out") == before
+ assert "QML_ROOT_MISMATCH" in capsys.readouterr().out
+
+
+@pytest.mark.parametrize("changed_name", ["Main.qml", "qmldir", "helpers.js"])
+def test_watch_qml_input_refreshes_the_live_code_corpus(tmp_path, monkeypatch, changed_name):
+ """QML-011 initial policy keeps unchanged components in the fresh resolver set."""
+ project = make_project(tmp_path)
+ monkeypatch.setitem(ex._DISPATCH, ".qml", healthy_qml)
+ assert _rebuild_code(project, no_cluster=True, acquire_lock=False)
+ source = project / changed_name
+ content = {
+ "Main.qml": "Item {}\n",
+ "qmldir": "module Example\nMain 1.0 Main.qml\n",
+ "helpers.js": "function helper() { return 1; }\n",
+ }
+ source.write_text(content[changed_name], encoding="utf-8")
+ called = []
+ real = ex.extract
+ def record(paths, **kwargs):
+ called.extend(p.name for p in paths)
+ return real(paths, **kwargs)
+ monkeypatch.setattr(ex, "extract", record)
+ assert _rebuild_code(project, changed_paths=[source], no_cluster=True, acquire_lock=False)
+ assert {"Main.qml", "keep.py"} <= set(called)
+
+
+def test_named_qml_metadata_watch_event_routes_to_local_rebuild(tmp_path):
+ """qmldir is extensionless metadata, not a semantic/LLM document."""
+ source = tmp_path / "qmldir"
+ source.write_text("module Example\n", encoding="utf-8")
+ assert _batch_triggers_rebuild([source])
+ assert not _batch_needs_llm_flag([source])
diff --git a/tests/test_qml_wheel_artifact.py b/tests/test_qml_wheel_artifact.py
new file mode 100644
index 0000000000..f981db86e2
--- /dev/null
+++ b/tests/test_qml_wheel_artifact.py
@@ -0,0 +1,74 @@
+"""QML-01 built-artifact contract, complementing clean dependency-install CI.
+
+Set GRAPHIFY_QML_TEST_WHEEL to a reviewed built wheel. These tests deliberately
+use installed host dependencies and force parser absence at the import boundary;
+they are not substitutes for isolated full-extra/core-only installation jobs.
+"""
+from __future__ import annotations
+
+from email.parser import Parser
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import zipfile
+
+from packaging.requirements import Requirement
+import pytest
+
+from tests.qml_test_helpers import WHEEL_PROBE, by_kind, write_qml
+
+
+@pytest.fixture(scope="module")
+def wheel():
+ """Do not rebuild/mutate the shared checkout or silently invent an artifact."""
+ value = os.environ.get("GRAPHIFY_QML_TEST_WHEEL")
+ if not value:
+ pytest.skip("Supply GRAPHIFY_QML_TEST_WHEEL from the built-wheel acceptance job")
+ path = Path(value)
+ assert path.is_file() and path.suffix == ".whl", "Reviewed built wheel unavailable"
+ return path
+
+
+def test_qml001_ac01_built_wheel_contains_adapter_and_optional_extra_metadata(wheel):
+ """Shipping metadata selects the pinned optional grammar rather than changing core install."""
+ with zipfile.ZipFile(wheel) as archive:
+ names = set(archive.namelist())
+ assert "graphify/extractors/qml.py" in names
+ assert "graphify/extractors/qml_ast.py" in names
+ assert "graphify/extractors/qml_declarations.py" in names
+ assert "graphify/extractors/qml_facts.py" in names
+ assert not any(name.startswith("tests/") for name in names)
+ metadata_name = next(name for name in names if name.endswith(".dist-info/METADATA"))
+ metadata = Parser().parsestr(archive.read(metadata_name).decode("utf-8"))
+ assert "qml" in metadata.get_all("Provides-Extra", [])
+ requirements = [Requirement(value) for value in metadata.get_all("Requires-Dist", [])]
+ grammar = [requirement for requirement in requirements if requirement.name == "tree-sitter-language-pack"]
+ assert any(requirement.marker is not None and requirement.marker.evaluate({"extra": "qml"}) and str(requirement.specifier) == "==0.11.0" for requirement in grammar)
+ assert all(requirement.marker is not None and not requirement.marker.evaluate({"extra": ""}) for requirement in grammar)
+
+
+@pytest.mark.parametrize("mode", ["qml", "core"])
+def test_qml001_ac01_ac03_built_artifact_production_import_and_parser_boundary(wheel, tmp_path, mode):
+ """A fresh interpreter imports the wheel artifact and exercises its actual adapter."""
+ target = tmp_path / "artifact"
+ target.mkdir()
+ with zipfile.ZipFile(wheel) as archive:
+ archive.extractall(target)
+ corpus = tmp_path / "corpus"
+ write_qml(corpus)
+ (corpus / "safe.py").write_text("def safe():\n return 1\n", encoding="utf-8")
+ cwd = tmp_path / "empty-working-directory"
+ cwd.mkdir()
+ child = subprocess.run([sys.executable, "-I", "-c", WHEEL_PROBE, str(target), str(corpus), mode], cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=45)
+ assert child.returncode == 0, child.stderr
+ result = json.loads(child.stdout)
+ assert result["python_ok"] is True
+ if mode == "qml":
+ assert not result["qml"].get("error")
+ assert len(by_kind(result["qml"], "component")) == 1
+ assert len(by_kind(result["qml"], "property")) == 6
+ else:
+ assert result["qml"].get("error") and result["qml"]["nodes"] == []
+ assert any(diagnostic["code"] == "QML_PARSER_MISSING" for diagnostic in result["qml"]["diagnostics"])
diff --git a/tests/test_qt_access_providers.py b/tests/test_qt_access_providers.py
new file mode 100644
index 0000000000..a81ffc1706
--- /dev/null
+++ b/tests/test_qt_access_providers.py
@@ -0,0 +1,129 @@
+"""QML-017 provider scope, meta-object connections and QQmlProperty production cases."""
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis, sites
+
+BACKEND = '''class Backend : public QObject {
+ Q_OBJECT
+ Q_PROPERTY(int count READ count)
+public:
+ int count() { return 1; }
+ Q_INVOKABLE void refresh() {}
+signals:
+ void changed();
+private slots:
+ void close() {}
+};
+'''
+QML = '''import QtQml
+QtObject { property int count: 1; function refresh() {} signal closed() }
+'''
+
+
+def test_qml_signals_to_cpp_private_slots_and_cpp_signals_to_qml_functions(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view; view.setSource(QUrl("{url}"));
+ auto root = view.rootObject();
+ QObject::connect(root, SIGNAL(closed()), backend, SLOT(close()));
+ QObject::connect(backend, SIGNAL(changed()), root, SLOT(refresh()));
+}}'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML})
+ connections = sites(result, "connect")
+ assert [qt_metadata(node)["status"] for node in connections] == ["resolved", "resolved"]
+ assert [qt_metadata(node)["bridge_direction"] for node in connections] == ["qml_to_cpp", "cpp_to_qml"]
+ assert not [edge for edge in result["edges"] if edge.get("context", "").startswith("qt_qml_connect") and edge["relation"] == "calls"]
+
+
+def test_qqmlproperty_read_write_preserves_property_handle(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject();
+ QQmlProperty handle(root, "count"); handle.read(); handle.write(2); }}'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": QML})
+ accesses = sites(result, "qml_access")
+ assert len(accesses) == 3
+ assert all(qt_metadata(node)["status"] == "resolved" for node in accesses), [qt_metadata(node) for node in accesses]
+ assert len({qt_metadata(node)["target_id"] for node in accesses}) == 1
+ static = source.replace('QQmlProperty handle(root, "count"); handle.read(); handle.write(2);', 'QQmlProperty::read(root, "count"); QQmlProperty::write(root, "count", 2);')
+ result2 = analysis(tmp_path, {"access.cpp": static, "Main.qml": QML})
+ assert all(qt_metadata(node)["status"] == "resolved" for node in sites(result2, "qml_access"))
+
+
+def test_context_provider_is_limited_to_loaded_component(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND + f'''void use(Backend *backend) {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("backend", backend); engine.load(QUrl("{url}")); }}'''
+ qml = 'import QtQml\nQtObject { property int value: backend.count; function run() { backend.refresh() } }'
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml, "Other.qml": qml})
+ bindings = sites(result, "context_binding")
+ assert len(bindings) == 1
+ accesses = sites(result, "context_access")
+ assert len(accesses) == 2
+ assert {node["source_file"] for node in accesses} == {"Main.qml"}
+
+
+def test_duplicate_context_provider_and_local_shadow_do_not_choose(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND + f'''void use(Backend *backend, Backend *other) {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("backend", backend);
+ engine.rootContext()->setContextProperty("backend", other); engine.load(QUrl("{url}")); }}'''
+ qml = 'import QtQml\nQtObject { property int value: backend.count }'
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ assert not sites(result, "context_access")
+ single_provider = source.replace('engine.rootContext()->setContextProperty("backend", other);', '')
+ qml2 = 'import QtQml\nQtObject { property var backend; property int value: backend.count; function run(backend) { backend.refresh() } }'
+ result2 = analysis(tmp_path, {"access.cpp": single_provider, "Main.qml": qml2})
+ assert not sites(result2, "context_access")
+
+
+def test_initial_property_provider_requires_declared_qml_property(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view;
+ view.setInitialProperties({{{{"backend", backend}}}}); view.setSource(QUrl("{url}")); }}'''
+ qml = 'import QtQml\nQtObject { property var backend; property int value: backend.count }'
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ assert len(sites(result, "context_binding")) == 1
+ assert len(sites(result, "context_access")) == 1
+ result2 = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml.replace('property var backend;', '')})
+ assert not sites(result2, "context_binding")
+
+
+def test_component_create_and_initial_properties_bind_accepted_component(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND + f'''void use(Backend *backend) {{ QQmlEngine engine;
+ QQmlComponent component(&engine, QUrl("{url}"));
+ QObject *object = component.createWithInitialProperties({{{{"backend", backend}}}});
+ object->setProperty("value", 2); }}'''
+ qml = 'import QtQml\nQtObject { property var backend; property int value: backend.count }'
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved"
+ assert qt_metadata(sites(result, "qml_root")[0])["status"] == "resolved"
+ assert qt_metadata(sites(result, "qml_access")[0])["status"] == "resolved"
+ assert len(sites(result, "context_access")) == 1
+
+
+def test_context_object_and_conditional_or_dynamic_providers(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND + f'''void use(Backend *backend, bool enabled) {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextObject(backend); engine.load(QUrl("{url}")); }}'''
+ qml = 'import QtQml\nQtObject { property int value: count }'
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ assert len(sites(result, "context_access")) == 1
+ conditional = source.replace('engine.rootContext()->setContextObject(backend);', 'if (enabled) engine.rootContext()->setContextObject(backend);')
+ result2 = analysis(tmp_path, {"access.cpp": conditional, "Main.qml": qml})
+ assert not sites(result2, "context_binding")
+ assert qt_metadata(sites(result2, "context_exposure")[0])["status"] == "dynamic"
+ dynamic = BACKEND + f'''void use(QVariantMap properties) {{ QQuickView view;
+ view.setInitialProperties(properties); view.setSource(QUrl("{url}")); }}'''
+ result3 = analysis(tmp_path, {"access.cpp": dynamic, "Main.qml": qml})
+ assert not sites(result3, "context_binding")
+ assert qt_metadata(sites(result3, "initial_properties")[0])["reason"] == "computed_initial_properties"
+
+
+def test_typed_qml_signal_and_untyped_function_use_explicit_meta_signatures(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND.replace('void changed();', 'void changed(QVariant value);').replace('void close() {}', 'void close(const QString &message) {}') + f'''void use(Backend *backend) {{ QQuickView view;
+ view.setSource(QUrl("{url}")); auto root = view.rootObject();
+ QObject::connect(root, SIGNAL(message(QString)), backend, SLOT(close(QString)));
+ QObject::connect(backend, SIGNAL(changed(QVariant)), root, SLOT(receive(QVariant))); }}'''
+ qml = 'import QtQml\nQtObject { signal message(msg: string); function receive(value) {} }'
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ assert all(qt_metadata(node)["status"] == "resolved" for node in sites(result, "connect")), [qt_metadata(node) for node in sites(result, "connect")]
diff --git a/tests/test_qt_affected_consumers.py b/tests/test_qt_affected_consumers.py
new file mode 100644
index 0000000000..737e3487ae
--- /dev/null
+++ b/tests/test_qt_affected_consumers.py
@@ -0,0 +1,49 @@
+"""QML-013-AC02 changed members reach source owners without propagation calls."""
+import pytest
+
+from graphify.affected import affected_nodes, load_graph
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_affected import owned_ancestors
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_signals_slots import SOURCE
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_property_change_reports_binding_and_owning_component(tmp_path, directed):
+ result = analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int input: 1; property int output: input }'})
+ graph = build_from_json(result, directed=directed, root=tmp_path)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path), force=True)
+ loaded = load_graph(path)
+ prop = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "property" and qml_metadata(node).get("raw_name") == "input")
+ component = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "component")
+ dependent = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "property" and qml_metadata(node).get("raw_name") == "output")
+ hits = {hit.node_id for hit in affected_nodes(loaded, prop["id"], depth=2)}
+ assert dependent["id"] in hits and component["id"] in hits
+
+
+def test_native_signal_changes_report_connection_owner_with_references_filter(tmp_path):
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ graph = build_from_json(result, directed=True, root=tmp_path)
+ signal = qt_metadata(sites(result, "connect")[0])["signal_target_id"]
+ connection = sites(result, "connect")[0]
+ hits = {hit.node_id for hit in affected_nodes(graph, signal, relations=["references"], depth=2)}
+ assert connection["id"] in hits and qt_metadata(connection)["owner_id"] in hits
+ assert not {connection["id"]} & {hit.node_id for hit in affected_nodes(graph, signal, relations=["calls"], depth=2)}
+
+
+def test_future_metadata_or_foreign_file_ownership_is_not_dependency_evidence(tmp_path):
+ """Only current source-owned contains links may promote a dependency site."""
+ result = analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int input: 1; property int output: input }'})
+ graph = build_from_json(result, directed=True, root=tmp_path)
+ read = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "read")
+ ancestors = list(owned_ancestors(graph, read["id"]))
+ assert ancestors and len(list(owned_ancestors(graph, read["id"], limit=2))) == 1
+ graph.nodes[read["id"]]["metadata"]["qml"]["contract_version"] = 2
+ assert not list(owned_ancestors(graph, read["id"]))
+ graph.nodes[read["id"]]["metadata"]["qml"]["contract_version"] = 1
+ graph.nodes[ancestors[0][0]]["source_file"] = "Other.qml"
+ assert not list(owned_ancestors(graph, read["id"]))
diff --git a/tests/test_qt_affected_definitions.py b/tests/test_qt_affected_definitions.py
new file mode 100644
index 0000000000..8e36510bba
--- /dev/null
+++ b/tests/test_qt_affected_definitions.py
@@ -0,0 +1,56 @@
+"""QML-016-AC04 canonical C++ header/implementation ownership in affected results."""
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from tests.qt_analysis_helpers import analysis, sites
+
+HEADER = '''class Emitter: public QObject { Q_OBJECT
+public: void wire(Emitter *other); void accept();
+signals: void changed();
+};'''
+SOURCE = '''void Emitter::wire(Emitter *other) {
+ QObject::connect(other, &Emitter::changed, this, &Emitter::accept);
+}
+void Emitter::accept() {}
+'''
+
+
+def fixture(tmp_path):
+ result = analysis(tmp_path, {"emitter.h": HEADER, "emitter.cpp": SOURCE})
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ site = sites(result, "connect")[0]
+ metadata = qt_metadata(site)
+ assert metadata["status"] == "resolved"
+ owner = graph.nodes[metadata["owner_id"]]
+ assert owner["source_file"] == "emitter.h" and owner["definition_file"] == "emitter.cpp"
+ return graph, site, metadata
+
+
+def test_qml016_ac04_signal_change_reports_canonical_out_of_line_function_owner(tmp_path):
+ """A real implementation site promotes its canonical header-owned method."""
+ graph, site, metadata = fixture(tmp_path)
+ hits = affected_nodes(graph, metadata["signal_target_id"], relations=["references"], depth=2)
+ by_id = {hit.node_id: hit for hit in hits}
+ assert site["id"] in by_id and metadata["owner_id"] in by_id
+ assert by_id[metadata["owner_id"]].via_file == "emitter.cpp"
+ assert by_id[metadata["owner_id"]].via_location == site["source_location"]
+ assert metadata["owner_id"] not in {hit.node_id for hit in affected_nodes(graph, metadata["signal_target_id"], relations=["calls"], depth=2)}
+
+
+@pytest.mark.parametrize("invalid", ["definition", "callable", "owner", "span"])
+def test_qml016_ac04_foreign_or_unproven_definition_cannot_promote_owner(tmp_path, invalid):
+ """Borrowed source fields alone cannot authorize unrelated function ownership."""
+ graph, site, metadata = fixture(tmp_path)
+ owner = graph.nodes[metadata["owner_id"]]
+ if invalid == "definition":
+ owner["definition_file"] = "other.cpp"
+ elif invalid == "callable":
+ owner.pop("_callable")
+ elif invalid == "owner":
+ update_qt(graph.nodes[site["id"]], owner_id="foreign_owner")
+ else:
+ update_qt(graph.nodes[site["id"]], span={**metadata["span"], "end_byte": metadata["span"]["end_byte"] + 1})
+ hits = {hit.node_id for hit in affected_nodes(graph, metadata["signal_target_id"], relations=["references"], depth=2)}
+ assert site["id"] in hits and metadata["owner_id"] not in hits
diff --git a/tests/test_qt_affected_direction.py b/tests/test_qt_affected_direction.py
new file mode 100644
index 0000000000..5507f51b84
--- /dev/null
+++ b/tests/test_qt_affected_direction.py
@@ -0,0 +1,246 @@
+"""INC-QML-30: affected traversal preserves accepted serialized edge direction."""
+from __future__ import annotations
+
+import json
+
+import networkx as nx
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.paths import load_node_link_graph
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.qt_affected import owned_ancestors
+from graphify.serve import _query_graph_text
+import graphify.__main__ as mainmod
+from tests.qt_adoption_fixture import sources
+from tests.qt_analysis_helpers import analysis, sites
+
+
+@pytest.mark.parametrize("expression", ["factory->makeBackend()", "backend"])
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("reverse_insertion", [False, True])
+def test_native_provider_affected_uses_accepted_serialized_direction(tmp_path, monkeypatch, capsys,
+ expression, directed, reverse_insertion):
+ """The real source access remains an incoming dependency of its native endpoint after JSON reload."""
+ result = analysis(tmp_path, sources(tmp_path, expression))
+ if reverse_insertion:
+ result["nodes"] = list(reversed(result["nodes"]))
+ result["edges"] = list(reversed(result["edges"]))
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ access = next(node for node in sites(result, "context_access")
+ if qt_metadata(node)["endpoint_proof"]["kind"] == "function")
+ target = qt_metadata(access)["endpoint_proof"]["canonical_target_id"]
+ assert reloaded.has_edge(access["id"], target)
+ if not directed:
+ metadata = reloaded.edges[access["id"], target]
+ assert metadata["_src"] == access["id"] and metadata["_tgt"] == target
+ hits = {hit.node_id: hit for hit in affected_nodes(reloaded, target, depth=3)}
+ assert access["id"] in hits
+ assert hits[access["id"]].via_file == "Main.qml"
+ assert hits[access["id"]].via_location == access["source_location"]
+ assert "refresh" in _query_graph_text(reloaded, "refresh", token_budget=1500)
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda *_: None)
+ monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "affected", target, "--graph", str(path), "--depth", "3"])
+ mainmod.main()
+ out = capsys.readouterr().out
+ assert reloaded.nodes[access["id"]]["label"] in out
+ assert f"Main.qml:{access['source_location']}" in out
+
+
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("reverse_insertion", [False, True])
+def test_logical_member_seed_finds_caller_without_reporting_seeded_method(directed, reverse_insertion):
+ """Physical iteration order cannot reverse either the root's member seed or incoming caller."""
+ graph = nx.DiGraph() if directed else nx.Graph()
+ order = ["class", "method", "caller"]
+ graph.add_nodes_from(reversed(order) if reverse_insertion else order)
+ graph.add_edge("class", "method", relation="method", _src="class", _tgt="method")
+ graph.add_edge("caller", "method", relation="calls", _src="caller", _tgt="method",
+ source_file="consumer.py", source_location="L17")
+ hits = affected_nodes(graph, "class", depth=1)
+ assert [(hit.node_id, hit.depth, hit.via_relation, hit.via_file, hit.via_location) for hit in hits] == [
+ ("caller", 1, "calls", "consumer.py", "L17")]
+ assert not affected_nodes(graph, "class", relations=["references"], depth=2)
+
+
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("direction", [
+ {"_src": "caller"}, {"_tgt": "target"}, {"_src": "foreign", "_tgt": "target"},
+ {"_src": "caller", "_tgt": "foreign"}, {"_src": None, "_tgt": "target"},
+ {"_src": ["caller"], "_tgt": "target"}, {"_src": "caller", "_tgt": {"id": "target"}},
+])
+def test_corrupt_direction_cannot_fall_back_to_physical_incoming(directed, direction):
+ """Partial, foreign or malformed transport rejects its edge rather than becoming legacy direction."""
+ graph = nx.DiGraph() if directed else nx.Graph()
+ graph.add_nodes_from(["caller", "target", "foreign"])
+ graph.add_edge("caller", "target", relation="references", **direction)
+ assert not affected_nodes(graph, "target", depth=1)
+
+
+def test_directed_direction_must_agree_with_physical_endpoints():
+ """A directed edge cannot claim the opposite orientation and silently change its dependency."""
+ graph = nx.DiGraph()
+ graph.add_edge("caller", "target", relation="calls", _src="target", _tgt="caller")
+ assert not affected_nodes(graph, "target", depth=1)
+
+
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("reverse_insertion", [False, True])
+def test_legacy_edges_without_direction_keep_existing_traversal(directed, reverse_insertion):
+ """An unmarked generic graph retains its historical raw endpoint interpretation."""
+ graph = nx.DiGraph() if directed else nx.Graph()
+ order = ["caller", "target"]
+ graph.add_nodes_from(reversed(order) if reverse_insertion else order)
+ graph.add_edge("caller", "target", relation="calls")
+ expected_source, expected_target = ("target", "caller") if reverse_insertion and not directed else ("caller", "target")
+ assert [hit.node_id for hit in affected_nodes(graph, expected_target, depth=1)] == [expected_source]
+ assert not affected_nodes(graph, expected_source, depth=1)
+
+
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("role", ["method", "contains"])
+def test_partial_member_direction_cannot_seed_reverse_traversal(directed, role):
+ """A root member edge with one missing marker cannot silently seed a caller's dependency."""
+ graph = nx.DiGraph() if directed else nx.Graph()
+ graph.add_edge("class", "member", relation=role, _src="class")
+ graph.add_edge("caller", "member", relation="calls", _src="caller", _tgt="member")
+ assert not affected_nodes(graph, "class", depth=2)
+
+
+@pytest.mark.parametrize("directed,corruption", [(False, "partial_source"), (False, "partial_target"),
+ (False, "foreign"), (True, "partial_source"), (True, "partial_target"), (True, "foreign"),
+ (True, "reversed_directed")])
+def test_qt_owner_promotion_rejects_partial_or_foreign_direction(tmp_path, directed, corruption):
+ """An accepted real QML read cannot promote a forged owner from damaged contains direction."""
+ result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"})
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ access = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "read")
+ owners = list(owned_ancestors(graph, access["id"]))
+ assert owners
+ owner, data = owners[0]
+ target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property"
+ and qml_metadata(node).get("raw_name") == "input")
+ assert owner in {hit.node_id for hit in affected_nodes(graph, target, depth=1)}
+ assert data["_src"] == owner and data["_tgt"] == access["id"]
+ if corruption == "partial_source":
+ data.pop("_src")
+ elif corruption == "partial_target":
+ data.pop("_tgt")
+ elif corruption == "foreign":
+ graph.add_node("foreign", label="Foreign", source_file=access["source_file"])
+ data["_src"] = "foreign"
+ else:
+ data["_src"], data["_tgt"] = data["_tgt"], data["_src"]
+ assert not list(owned_ancestors(graph, access["id"]))
+ hits = {hit.node_id for hit in affected_nodes(graph, target, depth=1)}
+ assert access["id"] in hits and owner not in hits and "foreign" not in hits
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_logical_direction_depth_cycles_and_repeated_query_preserve_graph(directed):
+ """Bounded reverse traversal ignores outgoing dependencies and terminates cycles without changing data."""
+ graph = nx.DiGraph() if directed else nx.Graph()
+ graph.add_nodes_from(["seed", "second", "first"])
+ for source, target in (("first", "seed"), ("second", "first"), ("seed", "second")):
+ graph.add_edge(source, target, relation="references", _src=source, _tgt=target)
+ before = nx.node_link_data(graph, edges="links")
+ assert not affected_nodes(graph, "seed", depth=0)
+ assert [(hit.node_id, hit.depth) for hit in affected_nodes(graph, "seed", depth=1)] == [("first", 1)]
+ expected = [("first", 1), ("second", 2)]
+ assert [(hit.node_id, hit.depth) for hit in affected_nodes(graph, "seed", depth=8)] == expected
+ assert [(hit.node_id, hit.depth) for hit in affected_nodes(graph, "seed", depth=8)] == expected
+ assert nx.node_link_data(graph, edges="links") == before
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_parallel_edges_keep_valid_filtered_mechanism_after_invalid_direction(directed):
+ """A rejected parallel edge cannot hide another accepted mechanism between the same endpoints."""
+ graph = nx.MultiDiGraph() if directed else nx.MultiGraph()
+ graph.add_nodes_from(["target", "caller"])
+ graph.add_edge("caller", "target", relation="calls", _src="foreign", _tgt="target")
+ graph.add_edge("caller", "target", relation="references", _src="caller", _tgt="target",
+ source_file="client.py", source_location="L27")
+ hits = affected_nodes(graph, "target", relations=["references"], depth=1)
+ assert [(hit.node_id, hit.via_relation, hit.via_location) for hit in hits] == [("caller", "references", "L27")]
+ assert not affected_nodes(graph, "target", relations=["calls"], depth=1)
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_boolean_marker_cannot_coerce_integer_endpoint_identity(directed):
+ """Serialized bool is not the accepted integer endpoint even though Python equality equates them."""
+ graph = nx.DiGraph() if directed else nx.Graph()
+ graph.add_edge(1, "target", relation="references", _src=True, _tgt="target")
+ assert not affected_nodes(graph, "target", depth=1)
+
+
+@pytest.mark.parametrize("markers", [{"_src": "source"}, {"_tgt": "target"},
+ {"_src": "foreign", "_tgt": "target"}, {"_src": None, "_tgt": "target"},
+ {"_src": ["source"], "_tgt": "target"}])
+def test_typed_reload_preserves_corrupt_dependency_direction_for_rejection(tmp_path, markers):
+ """Actual JSON reload must not replace damaged explicit markers with a trusted serialized pair."""
+ result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"})
+ graph = build_from_json(result, root=tmp_path)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ source = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read")
+ target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property"
+ and qml_metadata(node).get("raw_name") == "input")
+ original = json.loads(path.read_text(encoding="utf-8"))
+ # The real writer omits its internal markers. Untouched serialized producer
+ # endpoints remain the normal positive control before damaging this edge.
+ assert source in {hit.node_id for hit in affected_nodes(load_node_link_graph(path), target, depth=1)}
+ link = next(link for link in original["links"] if link["source"] == source and link["target"] == target)
+ link.update({key: source if value == "source" else target if value == "target" else value
+ for key, value in markers.items()})
+ path.write_text(json.dumps(original), encoding="utf-8")
+ reloaded = load_node_link_graph(path)
+ assert source not in {hit.node_id for hit in affected_nodes(reloaded, target, depth=1)}
+
+
+@pytest.mark.parametrize("markers", [{"_src": "source"}, {"_tgt": "target"},
+ {"_src": "foreign", "_tgt": "target"}])
+def test_typed_reload_preserves_corrupt_owner_direction_for_rejection(tmp_path, markers):
+ """A persisted real contains edge cannot acquire source ownership by overwriting partial/foreign markers."""
+ result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"})
+ graph = build_from_json(result, root=tmp_path)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ child = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read")
+ owner = list(owned_ancestors(graph, child))[0][0]
+ original = json.loads(path.read_text(encoding="utf-8"))
+ assert list(owned_ancestors(load_node_link_graph(path), child))
+ link = next(link for link in original["links"] if link["source"] == owner and link["target"] == child)
+ link.update({key: owner if value == "source" else child if value == "target" else value
+ for key, value in markers.items()})
+ # Give the forged endpoint an otherwise plausible same-file node, making
+ # endpoint-pair validation the actual protection rather than missing-node luck.
+ original["nodes"].append({"id": "foreign", "label": "Foreign", "source_file": "Main.qml"})
+ path.write_text(json.dumps(original), encoding="utf-8")
+ assert not list(owned_ancestors(load_node_link_graph(path), child))
+
+
+@pytest.mark.parametrize("reversed_pair", [False, True])
+def test_typed_reload_preserves_valid_complete_explicit_pair(tmp_path, reversed_pair):
+ """A valid complete pair retains its declared orientation instead of being silently replaced on reload."""
+ result = analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"})
+ graph = build_from_json(result, root=tmp_path)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ source = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read")
+ target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property"
+ and qml_metadata(node).get("raw_name") == "input")
+ data = json.loads(path.read_text(encoding="utf-8"))
+ link = next(link for link in data["links"] if link["source"] == source and link["target"] == target)
+ expected = (target, source) if reversed_pair else (source, target)
+ link["_src"], link["_tgt"] = expected
+ path.write_text(json.dumps(data), encoding="utf-8")
+ reloaded = load_node_link_graph(path)
+ attributes = reloaded.edges[source, target]
+ assert (attributes["_src"], attributes["_tgt"]) == expected
+ assert (source in {hit.node_id for hit in affected_nodes(reloaded, target, depth=1)}) is not reversed_pair
diff --git a/tests/test_qt_affected_direction_updates.py b/tests/test_qt_affected_direction_updates.py
new file mode 100644
index 0000000000..fa4c134ae1
--- /dev/null
+++ b/tests/test_qt_affected_direction_updates.py
@@ -0,0 +1,66 @@
+"""INC-QML-30: affected direction across real cold/manual/watch publication."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_adoption_fixture import sources
+from tests.qt_analysis_helpers import analysis
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+
+def native_dependency(graph):
+ """The accepted provider function, occurrence and call-site location form one consumer contract."""
+ candidates = [(identity, data) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") == "context_access"
+ and qt_metadata(data).get("endpoint_proof", {}).get("kind") == "function"]
+ assert len(candidates) == 1
+ identity, data = candidates[0]
+ target = qt_metadata(data)["endpoint_proof"]["canonical_target_id"]
+ hits = {hit.node_id: hit for hit in affected_nodes(graph, target, relations=["calls"], depth=1)}
+ assert identity in hits and hits[identity].depth == 1
+ assert (hits[identity].via_file, hits[identity].via_location) == ("Main.qml", data["source_location"])
+ return identity, target
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("expression", ["factory->makeBackend()", "backend"])
+def test_native_affected_direction_cold_warm_update_removal_recovery_and_repeat(tmp_path, monkeypatch,
+ operation, expression):
+ """A real QML edit retires its incoming access; repair restores cold-equivalent affected results."""
+ corpus = sources(tmp_path, expression)
+ corpus["keep.py"] = "def helper(): return 7\n\ndef retained(): return helper()\n"
+ analysis(tmp_path, corpus)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold-cache")
+ assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ access, target = native_dependency(initial)
+ assert native_dependency(cold) == (access, target)
+ kept = unrelated(initial)
+
+ # Keep the native declaration while removing this actual QML member use.
+ # The consumer must not report an access whose old source occurrence retired.
+ qml = tmp_path / "Main.qml"
+ original = qml.read_bytes()
+ qml.write_bytes(original.replace(b"backend.service.refresh()", b"backend.service.missing()"))
+ removed = run(tmp_path, monkeypatch, operation, [qml])
+ assert target in removed and access not in removed
+ assert not affected_nodes(removed, target, relations=["calls"], depth=1)
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ assert unrelated(removed) == kept
+
+ # Recovery and a no-change update exercise the durable production cohort;
+ # querying affected nodes itself must never rewrite the published graph.
+ qml.write_bytes(original)
+ repaired = run(tmp_path, monkeypatch, operation, [qml])
+ assert native_dependency(repaired) == (access, target)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache"))
+ output = tmp_path / "graphify-out/graph.json"
+ before = output.read_bytes()
+ repeated = run(tmp_path, monkeypatch, operation, [])
+ assert native_dependency(repeated) == (access, target)
+ assert output.read_bytes() == before and unrelated(repeated) == kept
diff --git a/tests/test_qt_alias_policy_upgrade.py b/tests/test_qt_alias_policy_upgrade.py
new file mode 100644
index 0000000000..3b42213597
--- /dev/null
+++ b/tests/test_qt_alias_policy_upgrade.py
@@ -0,0 +1,85 @@
+"""REQ-QML-020: unchanged native products refresh after alias policy upgrades."""
+from __future__ import annotations
+
+import pytest
+
+import graphify.extract as extraction
+import graphify.qt_analysis_state as state
+import graphify.qt_incremental as policy
+from graphify.watch import _rebuild_code
+from tests.test_qt_final_incremental_parity import clean, normalized, project, run
+from tests.test_qt_product_publication import no_scratch, snapshot
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("corpus", ["mixed", "cpp_only"])
+def test_req_qml020_ac03_policy18_no_change_upgrade_retains_repairs_and_repeats(
+ tmp_path, monkeypatch, capsys, operation, corpus):
+ """A prior valid cohort refreshes without edits; failed stamp publication retains it."""
+ project(tmp_path)
+ if corpus == "cpp_only":
+ # An ordinary native-only corpus also owns a Qt analysis stamp. Remove
+ # QML/build/resource inputs before admission and use no Qt annotations.
+ for name in ("Main.qml", "CMakeLists.txt", "resources.qrc", "backend.h"):
+ (tmp_path / name).unlink()
+ (tmp_path / "loader.cpp").write_bytes(b"int helper(){return 7;} int caller(){return helper();}\n")
+ required_inputs = ({"loader.cpp"} if corpus == "cpp_only" else
+ {"Main.qml", "backend.h", "loader.cpp", "CMakeLists.txt", "resources.qrc"})
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ # Produce the prior stamp through the real owner rather than constructing a
+ # counterfeit manifest. The source bytes and accepted graph stay unchanged.
+ with monkeypatch.context() as prior:
+ prior.setattr(policy, "QT_POLICY_VERSION", 18)
+ initial = run(tmp_path, prior, operation)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ prior_fingerprint = state.read_qt_fingerprint(output)
+ source_bytes = {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()}
+ observed, real_extract = [], extraction.extract
+
+ def record_inputs(paths, *args, **kwargs):
+ paths = list(paths)
+ observed.append({path.name for path in paths})
+ return real_extract(paths, *args, **kwargs)
+
+ monkeypatch.setattr(extraction, "extract", record_inputs)
+ committed, real_commit = [], state.commit_qt_analysis
+
+ def stage_then_fail(*args, **kwargs):
+ # Candidate stamp serialization really completes. Failure before cohort
+ # replacement cannot authorize any graph/manifest/root/stamp advancement.
+ real_commit(*args, **kwargs)
+ committed.append(True)
+ raise OSError("public policy upgrade publication failure")
+
+ with monkeypatch.context() as failed:
+ failed.setattr(state, "commit_qt_analysis", stage_then_fail)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failed, operation, [])
+ assert rejected.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[], no_cluster=True)
+ assert committed == [True]
+ assert snapshot(output) == before
+ assert state.read_qt_fingerprint(output) == prior_fingerprint
+ assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out
+ assert observed and required_inputs <= set.union(*observed)
+ no_scratch(output)
+
+ observed.clear()
+ repaired = run(tmp_path, monkeypatch, operation, [])
+ assert normalized(repaired) == normalized(initial)
+ assert state.read_qt_fingerprint(output) != prior_fingerprint
+ assert observed and required_inputs <= set.union(*observed)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean-cache"))
+ accepted = snapshot(output)
+ observed.clear()
+ repeated = run(tmp_path, monkeypatch, operation, [])
+ assert normalized(repeated) == normalized(repaired)
+ assert snapshot(output) == accepted
+ if operation == "watch":
+ assert not any(observed)
+ assert source_bytes == {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()}
+ no_scratch(output)
diff --git a/tests/test_qt_analysis_state.py b/tests/test_qt_analysis_state.py
new file mode 100644
index 0000000000..89fe486433
--- /dev/null
+++ b/tests/test_qt_analysis_state.py
@@ -0,0 +1,134 @@
+"""QML-011 config/parser-contract state; incomplete stages cannot advance a stamp."""
+import json
+
+import pytest
+
+from graphify.qt_analysis_state import (
+ QT_STATE_FILE, commit_qt_analysis, inspect_qt_analysis, qt_configuration_changed,
+ read_qt_fingerprint, save_qt_fingerprint,
+)
+from graphify.qt_incremental import qt_analysis_fingerprint
+
+
+def fingerprint(**changes):
+ return qt_analysis_fingerprint(**{"parser_version": "qmljs-test-1", "fact_version": 1, **changes})
+
+
+def test_successful_configuration_checkpoint_and_contract_change_require_refresh(tmp_path):
+ original = fingerprint()
+ assert qt_configuration_changed(tmp_path, original)
+ assert not tmp_path.joinpath(QT_STATE_FILE).exists()
+ save_qt_fingerprint(tmp_path, original)
+ assert read_qt_fingerprint(tmp_path) == original
+ assert not qt_configuration_changed(tmp_path, original)
+ for changed in (fingerprint(parser_version="qmljs-test-2"), fingerprint(fact_version=2),
+ fingerprint(import_roots=["imports", "."]), fingerprint(ignore_patterns=["generated/"])):
+ assert qt_configuration_changed(tmp_path, changed)
+ assert read_qt_fingerprint(tmp_path) == original
+ payload = json.loads(tmp_path.joinpath(QT_STATE_FILE).read_text())
+ assert set(payload) == {"schema", "fingerprint", "has_qt"}
+
+
+@pytest.mark.parametrize("raw", [
+ b"invalid", b"\xff", b"[]", b'{"schema":2,"fingerprint":"' + b"a" * 64 + b'"}',
+ b'{"schema":true,"fingerprint":"' + b"a" * 64 + b'"}',
+ b'{"schema":1,"fingerprint":"bad"}', b"x" * 1025,
+])
+def test_corrupt_or_incompatible_state_cannot_validate_cached_resolution(tmp_path, raw):
+ path = tmp_path / QT_STATE_FILE
+ path.write_bytes(raw)
+ assert read_qt_fingerprint(tmp_path) is None
+ assert qt_configuration_changed(tmp_path, fingerprint())
+ assert path.read_bytes() == raw
+
+
+def test_invalid_checkpoint_and_failed_publication_do_not_replace_last_valid_state(tmp_path, monkeypatch):
+ save_qt_fingerprint(tmp_path, fingerprint())
+ original = (tmp_path / QT_STATE_FILE).read_bytes()
+ with pytest.raises(ValueError, match="QT_CONFIG"):
+ save_qt_fingerprint(tmp_path, "invalid")
+ def unavailable(*_args, **_kwargs):
+ raise OSError("isolated persistence failure")
+ monkeypatch.setattr("graphify.qt_analysis_state.write_text_atomic", unavailable)
+ with pytest.raises(OSError):
+ save_qt_fingerprint(tmp_path, fingerprint(fact_version=2))
+ assert (tmp_path / QT_STATE_FILE).read_bytes() == original
+
+
+def test_inspection_import_root_order_and_known_input_inventory(tmp_path, monkeypatch):
+ root, out = tmp_path / "root", tmp_path / "out"
+ root.mkdir()
+ path = root / "Main.qml"
+ path.write_text("Item {}")
+ state = inspect_qt_analysis(root, out, [path])
+ assert state.changed and state.has_qt and state.import_roots == (".",)
+ assert not out.exists()
+ commit_qt_analysis(out, state)
+ assert not inspect_qt_analysis(root, out, [path]).changed
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["imports", "."]')
+ imported = inspect_qt_analysis(root, out, [path])
+ assert imported.changed and imported.import_roots == ("imports", ".")
+ assert not (root / "imports").exists()
+ commit_qt_analysis(out, imported)
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '[".", "imports"]')
+ assert inspect_qt_analysis(root, out, [path]).changed
+ removed = inspect_qt_analysis(root, out, [])
+ assert removed.changed and removed.has_qt and not removed.current_has_qt
+ commit_qt_analysis(out, removed)
+ assert not inspect_qt_analysis(root, out, []).has_qt
+
+
+def test_package_fact_policy_and_ignore_configuration_invalidate_unchanged_qml(tmp_path, monkeypatch):
+ from graphify.qt_incremental import QT_POLICY_VERSION
+
+ path = tmp_path / "nested/Main.qml"
+ path.parent.mkdir()
+ path.write_text("Item {}")
+ out = tmp_path / "out"
+ monkeypatch.setattr("graphify.qt_analysis_state._package_versions", lambda: "test-pack-1")
+ state = inspect_qt_analysis(tmp_path, out, [path])
+ commit_qt_analysis(out, state)
+ monkeypatch.setattr("graphify.qt_analysis_state._package_versions", lambda: "test-pack-2")
+ assert inspect_qt_analysis(tmp_path, out, [path]).changed
+ monkeypatch.setattr("graphify.qt_analysis_state._package_versions", lambda: "test-pack-1")
+ monkeypatch.setattr("graphify.qt_analysis_state.CONTRACT_VERSION", 2)
+ assert inspect_qt_analysis(tmp_path, out, [path]).changed
+ monkeypatch.setattr("graphify.qt_analysis_state.CONTRACT_VERSION", 1)
+ monkeypatch.setattr("graphify.qt_incremental.QT_POLICY_VERSION", QT_POLICY_VERSION + 1)
+ assert inspect_qt_analysis(tmp_path, out, [path]).changed
+ monkeypatch.setattr("graphify.qt_incremental.QT_POLICY_VERSION", QT_POLICY_VERSION)
+ ignore = path.parent / ".graphifyignore"
+ ignore.write_bytes(b"ignored.qml\n")
+ assert inspect_qt_analysis(tmp_path, out, [path]).changed
+ ignore.unlink()
+ (tmp_path / ".gitignore").write_bytes(b"generated/\n")
+ assert inspect_qt_analysis(tmp_path, out, [path]).changed
+ assert not inspect_qt_analysis(tmp_path, out, [path], gitignore=False).fingerprint == state.fingerprint
+ assert inspect_qt_analysis(tmp_path, out, [path], excludes=["generated/"]).changed
+
+
+@pytest.mark.parametrize("raw", ["invalid", "{}", "[1]", '["../outside"]', '["/outside"]',
+ '["C:/outside"]', '[""]', '["a/../../outside"]',
+ "[" + ",".join('"a"' for _ in range(257)) + "]", "x" * 65537],
+ ids=["invalid-json", "object", "number", "parent", "absolute", "drive",
+ "empty", "traversal", "count-limit", "byte-limit"])
+def test_malformed_or_outside_import_config_rejects_before_stamp_write(tmp_path, monkeypatch, raw):
+ if len(raw) > 32767:
+ monkeypatch.setattr("graphify.qt_analysis_state.os.environ", {"GRAPHIFY_QML_IMPORT_ROOTS": raw})
+ else:
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", raw)
+ with pytest.raises(ValueError, match="QT_CONFIG"):
+ inspect_qt_analysis(tmp_path, tmp_path / "out", [])
+ assert not (tmp_path / "out").exists()
+
+
+def test_inspection_bounded_ignores_and_outside_inputs_do_not_read_host_files(tmp_path):
+ root = tmp_path / "root"
+ root.mkdir()
+ (root / ".graphifyignore").write_bytes(b"x" * 1_048_577)
+ with pytest.raises(ValueError, match="QT_CONFIG_LIMIT"):
+ inspect_qt_analysis(root, root / "out", [])
+ with pytest.raises(ValueError, match="QT_CONFIG"):
+ inspect_qt_analysis(root, root / "out", [tmp_path / "host.qml"])
+ with pytest.raises(ValueError, match="QT_CONFIG"):
+ inspect_qt_analysis(root, root / "out", [root])
diff --git a/tests/test_qt_bridge_mechanism_integrity.py b/tests/test_qt_bridge_mechanism_integrity.py
new file mode 100644
index 0000000000..39c2fed420
--- /dev/null
+++ b/tests/test_qt_bridge_mechanism_integrity.py
@@ -0,0 +1,188 @@
+"""INC-QML-36: accepted source mechanisms survive every bridge consumer boundary."""
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import encode_metadata, qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.paths import load_node_link_graph
+from graphify.qt_qml_projection import allows_qt_qml_edge
+from tests.qt_adoption_fixture import sources
+from tests.qt_analysis_helpers import analysis
+from tests.test_qt_access_providers import BACKEND
+
+
+def fixture(root, profile):
+ """Only ordinary public source fixtures produce the bridge facts under review."""
+ if profile == "context":
+ inputs = sources(root)
+ elif profile == "native":
+ native = BACKEND.replace("changed", "ready")
+ native += 'void registerTypes() { qmlRegisterType("Demo.Tools", 1, 0, "Service"); }'
+ qml = ('import QtQml\nimport Demo.Tools 1.0\nService { id: service; '
+ 'property int displayed: count; property alias mirrored: service.count; '
+ 'function run() { refresh(); ready() } onReady: {} }')
+ inputs = {"backend.cpp": native, "Main.qml": qml}
+ else:
+ url = (root / "Main.qml").as_uri()
+ native = BACKEND + f'''void use(Backend *backend) {{ QQuickView view;
+ view.setSource(QUrl("{url}")); auto root = view.rootObject();
+ root->property("count"); root->setProperty("count", 2);
+ QMetaObject::invokeMethod(root, "refresh"); QMetaObject::invokeMethod(root, "closed");
+ auto child = root->findChild("details"); child->property("count");
+ QObject::connect(root, SIGNAL(closed()), backend, SLOT(close()));
+ QObject::connect(backend, SIGNAL(changed()), root, SLOT(refresh())); }}'''
+ if profile == "disconnect":
+ native = native.replace("QObject::connect", "QObject::disconnect")
+ qml = ('import QtQml\nQtObject { property int count: 1; function refresh() {} signal closed(); '
+ 'property QtObject child: QtObject { objectName: "details"; property int count: 2 } }')
+ inputs = {"access.cpp": native, "Main.qml": qml}
+ inputs["keep.py"] = "def helper(): return 7\ndef retained(): return helper()\n"
+ return analysis(root, inputs)
+
+
+def selected(result, context):
+ nodes = {node["id"]: node for node in result["nodes"]}
+ return next(edge for edge in result["edges"] if edge.get("context") == context
+ and (Path(nodes[edge["source"]]["source_file"]).suffix == ".qml")
+ != (Path(nodes[edge["target"]]["source_file"]).suffix == ".qml"))
+
+
+def accepted(result, edge):
+ nodes = {node["id"]: node for node in result["nodes"]}
+ return allows_qt_qml_edge(nodes[edge["source"]], nodes[edge["target"]], edge,
+ source_id=edge["source"], target_id=edge["target"], nodes=nodes)
+
+
+CASES = [("context", value) for value in ("qt_context_member", "qt_context_subscription")]
+CASES += [("native", value) for value in ("qml_import_resolution", "qml_type_resolution",
+ "qml_binding_read", "qml_alias_target", "qml_js_call", "qml_signal_emit", "qml_signal_subscription")]
+CASES += [("reverse", value) for value in ("qt_cpp_qml_load", "qt_cpp_qml_property_read",
+ "qt_cpp_qml_property_write", "qt_cpp_qml_find_child", "qt_cpp_qml_invoke",
+ "qt_qml_connect_signal", "qt_qml_connect_receiver")]
+CASES += [("disconnect", value) for value in ("qt_qml_disconnect_signal", "qt_qml_disconnect_receiver")]
+
+
+@pytest.mark.parametrize("profile,context", CASES)
+@pytest.mark.parametrize("directed", [False, True])
+def test_source_mechanisms_survive_builder_export_and_reload(tmp_path, profile, context, directed):
+ """Valid source facts retain their observed operation rather than becoming generic calls."""
+ result = fixture(tmp_path, profile)
+ edge = selected(result, context)
+ assert accepted(result, edge)
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ assert graph.has_edge(edge["source"], edge["target"])
+ path = tmp_path / "accepted.json"
+ assert to_json(graph, {}, str(path), force=True)
+ restored = load_node_link_graph(path)
+ source, target = edge["source"], edge["target"]
+ data = restored.edges[source, target]
+ assert data["relation"] == edge["relation"] and data["context"] == context
+ assert allows_qt_qml_edge(restored.nodes[source], restored.nodes[target], data,
+ source_id=source, target_id=target, nodes=restored.nodes)
+ assert any(node.get("source_file") == "keep.py" for _, node in restored.nodes(data=True))
+
+
+@pytest.mark.parametrize("profile,context", CASES)
+@pytest.mark.parametrize("corruption", ["relation", "context", "confidence", "inverse", "status", "span"])
+def test_source_mechanism_substitution_is_rejected_before_graph_assembly(tmp_path, profile, context, corruption):
+ """A changed real producer edge/site cannot retain a valid-looking endpoint proof."""
+ result = fixture(tmp_path, profile)
+ edge = selected(result, context)
+ # Disconnect projection was previously missing from the consumer's bounded
+ # whitelist despite being emitted by the ordinary cross-language resolver.
+ if "disconnect" not in context:
+ assert accepted(result, edge)
+ nodes = {node["id"]: node for node in result["nodes"]}
+ source = nodes[edge["source"]]
+ if corruption == "relation":
+ edge["relation"] = "references" if edge["relation"] == "calls" else "calls"
+ elif corruption == "confidence":
+ edge["confidence"] = "EXTRACTED"
+ elif corruption == "context":
+ edge["context"] = "qt_cpp_qml_invoke" if profile in {"reverse", "disconnect"} else "qml_js_call"
+ if context == edge["context"]:
+ edge["context"] = "qt_cpp_qml_property_read" if profile == "reverse" else "qml_binding_read"
+ elif corruption == "inverse":
+ edge["source"], edge["target"] = edge["target"], edge["source"]
+ edge["_src"], edge["_tgt"] = edge["source"], edge["target"]
+ elif profile == "native":
+ md = {key: value for key, value in qml_metadata(source).items() if key != "raw_values"}
+ md.update(status="unavailable") if corruption == "status" else md.update(span={**md["span"], "start_byte": 999999})
+ source["metadata"]["qml"] = encode_metadata(md)
+ else:
+ md = qt_metadata(source)
+ update_qt(source, **({"status": "unavailable"} if corruption == "status"
+ else {"span": {**md["span"], "start_byte": 999999}}))
+ assert not accepted(result, edge)
+ graph = build_from_json(result, root=tmp_path)
+ assert not graph.has_edge(edge["source"], edge["target"])
+ assert any(node.get("source_file") == "keep.py" for _, node in graph.nodes(data=True))
+
+
+@pytest.mark.parametrize("corruption", ["owner_kind", "owner_status", "owner_span", "owner_file", "owner_location", "lexical"])
+def test_context_access_revalidates_original_read_or_call(tmp_path, corruption):
+ """The derived access repeats source ownership and lexical admission after serialization."""
+ result = fixture(tmp_path, "context")
+ edge = selected(result, "qt_context_member")
+ nodes = {node["id"]: node for node in result["nodes"]}
+ source = nodes[edge["source"]]
+ owner = nodes[qt_metadata(source)["owner_id"]]
+ md = {key: value for key, value in qml_metadata(owner).items() if key != "raw_values"}
+ if corruption == "owner_file":
+ owner["source_file"] = "Other.qml"
+ elif corruption == "owner_location":
+ owner["source_location"] = "L999"
+ else:
+ fields = {"owner_kind": {"kind": "alias"}, "owner_status": {"status": "dynamic"},
+ "owner_span": {"span": {**md["span"], "start_byte": 999999}}, "lexical": {"lexical_shadowed": True}}
+ owner["metadata"]["qml"] = encode_metadata({**md, **fields[corruption]})
+ assert not accepted(result, edge)
+
+
+@pytest.mark.parametrize("profile", ["native", "reverse"])
+def test_endpoint_role_cannot_be_changed_to_repair_relation(tmp_path, profile):
+ """Changing the proof's display kind cannot replace the original signal/property/function role."""
+ result = fixture(tmp_path, profile)
+ edge = selected(result, "qml_js_call" if profile == "native" else "qt_qml_connect_signal")
+ assert accepted(result, edge)
+ if profile == "native":
+ md = qt_metadata(edge)
+ proof = copy.deepcopy(md["native_endpoint"])
+ proof["kind"] = "signal"
+ update_qt(edge, native_endpoint=proof)
+ edge["relation"], edge["context"] = "uses", "qml_signal_emit"
+ else:
+ source = next(node for node in result["nodes"] if node["id"] == edge["source"])
+ update_qt(source, role="receiver")
+ edge["context"] = "qt_qml_connect_receiver"
+ assert not accepted(result, edge)
+
+
+@pytest.mark.parametrize("profile,context", [case for case in CASES if case[0] != "native"])
+def test_cross_language_flow_cannot_be_reversed_by_metadata(tmp_path, profile, context):
+ """Dependency endpoints and the observed QML/C++ flow retain separate, consistent authority."""
+ result = fixture(tmp_path, profile)
+ edge = selected(result, context)
+ assert accepted(result, edge)
+ old = qt_metadata(edge)["bridge_direction"]
+ update_qt(edge, bridge_direction="cpp_to_qml" if old == "qml_to_cpp" else "qml_to_cpp")
+ assert not accepted(result, edge)
+ assert not build_from_json(result, root=tmp_path).has_edge(edge["source"], edge["target"])
+
+
+@pytest.mark.parametrize("field", ["conditional", "reflection_supported", "operation"])
+def test_reverse_reflection_repeats_original_supported_operation(tmp_path, field):
+ """Resolved transport cannot repair a newly conditional or rejected reflection operation."""
+ result = fixture(tmp_path, "reverse")
+ edge = selected(result, "qt_cpp_qml_property_read")
+ source = next(node for node in result["nodes"] if node["id"] == edge["source"])
+ assert accepted(result, edge)
+ update_qt(source, **{field: {"conditional": True, "reflection_supported": False, "operation": "invokeMethod"}[field]})
+ assert not accepted(result, edge)
+ assert not build_from_json(result, root=tmp_path).has_edge(edge["source"], edge["target"])
diff --git a/tests/test_qt_combined_adoption.py b/tests/test_qt_combined_adoption.py
new file mode 100644
index 0000000000..337c39f3eb
--- /dev/null
+++ b/tests/test_qt_combined_adoption.py
@@ -0,0 +1,79 @@
+"""REQ-QML-018-AC05/AC06/AC07: combined profile through actual CLI and durable consumers."""
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+import graphify.extract as extraction
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.paths import load_node_link_graph
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_combined_adoption_fixture import corpus
+from tests.test_qt_final_incremental_parity import normalized, run
+
+
+def accepted(graph):
+ """Assert exact source-backed APIs, subscriptions and constructor emission ownership."""
+ bindings = [qt_metadata(node) for _, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == "context_binding"]
+ assert len(bindings) == 1
+ accesses = [qt_metadata(node) for _, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == "context_access"]
+ assert len(accesses) >= 3 and all(md["status"] == "resolved" for md in accesses)
+ subscriptions = [(identity, qt_metadata(node)) for identity, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == "context_subscription"]
+ assert len(subscriptions) == 2 and all(md["status"] == "resolved" for _, md in subscriptions)
+ for identity, _ in subscriptions:
+ assert not any(edge.get("relation") == "calls" for _, _, edge in graph.edges(identity, data=True))
+ emissions = [qt_metadata(node) for _, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == "emission"]
+ assert len(emissions) == 1 and emissions[0]["status"] == "resolved"
+ owner = graph.nodes[emissions[0]["owner_id"]]
+ assert owner.get("metadata", {}).get("cpp_constructor")
+ assert qt_metadata(graph.nodes[emissions[0]["target_id"]])["class_name"] == "Grand"
+ imports = [qml_metadata(node) for _, node in graph.nodes(data=True)
+ if qml_metadata(node).get("kind") == "import_resolution"
+ and node.get("label") == "Public.Extra"]
+ assert imports and all(md["status"] == "resolved" for md in imports)
+
+
+def clean(root, cache):
+ """Use the same explicit analysis roots as the CLI; the facade owns no ambient config."""
+ paths = extraction.collect_files(root, root=root)
+ result = extraction.extract(paths, root=root, cache_root=cache, parallel=False,
+ qml_import_roots=tuple(json.loads(os.environ["GRAPHIFY_QML_IMPORT_ROOTS"])))
+ assert not result["failed_sources"] and not result["qml_failures"]
+ graph = build_from_json(result, root=root)
+ path = root / "graphify-out/combined-comparison.json"
+ assert to_json(graph, {}, str(path), force=True)
+ return load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+
+
+@pytest.mark.parametrize("build_system", ["cmake", "qmake"])
+@pytest.mark.parametrize("scope", ["whole", "subfolder"])
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac05_combined_configured_project_matches_cold_warm_and_updates(tmp_path, monkeypatch, build_system, scope, operation):
+ """Each explicit root has its own stable IDs; no cross-root rebasing is inferred."""
+ app = corpus(tmp_path, build_system)
+ root = tmp_path if scope == "whole" else app
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["app/imports"]' if scope == "whole" else '["imports"]')
+ baseline = clean(root, root / ".cold-cache")
+ accepted(baseline)
+ assert normalized(clean(root, root / ".cold-cache")) == normalized(baseline)
+ actual = run(root, monkeypatch, operation)
+ accepted(actual)
+ assert normalized(actual) == normalized(baseline)
+ assert normalized(run(root, monkeypatch, operation, [])) == normalized(actual)
+ # QML-only edits rejoin provider and callback declarations from unchanged C++.
+ path = app / "Main.qml"
+ path.write_bytes(path.read_bytes().replace(b"handleReady", b"received"))
+ edited = run(root, monkeypatch, operation, [path])
+ accepted(edited)
+ assert normalized(edited) == normalized(clean(root, root / ".edited-cache"))
diff --git a/tests/test_qt_combined_adoption_safety.py b/tests/test_qt_combined_adoption_safety.py
new file mode 100644
index 0000000000..cb632410f0
--- /dev/null
+++ b/tests/test_qt_combined_adoption_safety.py
@@ -0,0 +1,84 @@
+"""REQ-QML-018-AC06: combined installed profile retains products at real failure boundaries."""
+from __future__ import annotations
+
+import builtins
+import os
+import stat
+
+import pytest
+
+from graphify.cache import cache_dir
+from tests.qt_combined_adoption_fixture import corpus
+from tests.test_qt_combined_adoption import accepted, clean
+from tests.test_qt_final_incremental_parity import normalized, run
+from tests.test_qt_product_publication import reject, snapshot
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("failure", ["missing_optional_parser", "malformed_qml", "unsafe_import_root"])
+def test_req_qml018_ac06_combined_rejection_retains_cache_products_and_retries(tmp_path, monkeypatch, capsys, operation, failure):
+ """Actual parser import/syntax or configured-root rejection cannot accept a new graph."""
+ app = corpus(tmp_path, "qmake")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["app/imports"]')
+ initial = run(tmp_path, monkeypatch, operation)
+ accepted(initial)
+ before = snapshot(tmp_path / "graphify-out")
+ cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*") if path.is_file()}
+ assert cache
+ qml = app / "Main.qml"
+ original = qml.read_bytes()
+ qml.write_bytes(original + (b"Item {\n" if failure == "malformed_qml" else b"\n// changed\n"))
+ with monkeypatch.context() as broken:
+ if failure == "missing_optional_parser":
+ original_import = builtins.__import__
+ def absent(name, *args, **kwargs):
+ if name == "tree_sitter_language_pack":
+ raise ModuleNotFoundError("unavailable optional parser")
+ return original_import(name, *args, **kwargs)
+ broken.setattr(builtins, "__import__", absent)
+ elif failure == "unsafe_import_root":
+ broken.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["../outside"]')
+ reject(tmp_path, broken, operation, qml)
+ assert snapshot(tmp_path / "graphify-out") == before
+ assert all(path.exists() and path.read_bytes() == value for path, value in cache.items())
+ output = capsys.readouterr().out
+ assert ("QML_GRAPH_PRESERVED" if failure != "unsafe_import_root" else "QT_CONFIG") in output
+ qml.write_bytes(original)
+ recovered = run(tmp_path, monkeypatch, operation, [qml])
+ accepted(recovered)
+ assert normalized(recovered) == normalized(initial)
+ assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".retry-cache"))
+ completed = snapshot(tmp_path / "graphify-out")
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered)
+ assert snapshot(tmp_path / "graphify-out") == completed
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only destination")
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac06_combined_readonly_destination_preserves_then_retries(tmp_path, monkeypatch, operation):
+ """A real persisted graph failure occurs after successful combined source analysis."""
+ app = corpus(tmp_path, "cmake")
+ monkeypatch.chdir(app)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["imports"]')
+ initial = run(app, monkeypatch, operation)
+ accepted(initial)
+ before = snapshot(app / "graphify-out")
+ qml = app / "Main.qml"
+ qml.write_bytes(qml.read_bytes().replace(b"handleReady", b"received"))
+ target = app / "graphify-out/graph.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ reject(app, monkeypatch, operation, qml)
+ assert snapshot(app / "graphify-out") == before
+ finally:
+ target.chmod(stat.S_IWRITE)
+ recovered = run(app, monkeypatch, operation, [qml])
+ accepted(recovered)
+ assert normalized(recovered) != normalized(initial)
+ assert normalized(recovered) == normalized(clean(app, app / ".retry-cache"))
+ after = snapshot(app / "graphify-out")
+ assert normalized(run(app, monkeypatch, operation, [])) == normalized(recovered)
+ assert snapshot(app / "graphify-out") == after
diff --git a/tests/test_qt_config_incremental.py b/tests/test_qt_config_incremental.py
new file mode 100644
index 0000000000..d0490513cd
--- /dev/null
+++ b/tests/test_qt_config_incremental.py
@@ -0,0 +1,157 @@
+"""Real Qt configuration refresh and successful-publication stamp ordering."""
+from __future__ import annotations
+
+import json
+import pytest
+
+import graphify.__main__ as mainmod
+import graphify.extract as extraction
+import graphify.qt_analysis_state as state
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.paths import load_node_link_graph
+from graphify.watch import _rebuild_code
+
+
+def _write(root, path, source):
+ destination = root / path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(source, encoding="utf-8")
+ return destination
+
+
+def _project(root):
+ _write(root, "Main.qml", "import Public.Tools 1.0\nService { property int displayed: value }\n")
+ _write(root, "keep.py", "\n".join(f"def retained_{index}(): return {index}" for index in range(20)))
+ for directory, value in (("first", 1), ("second", 2)):
+ _write(root, f"{directory}/Public/Tools/qmldir", "module Public.Tools\nService 1.0 Service.qml\n")
+ _write(root, f"{directory}/Public/Tools/Service.qml", f"QtObject {{ property int value: {value} }}\n")
+
+
+def _run(root, monkeypatch, operation, *, initial=False, changes=None, force=False):
+ if operation == "watch":
+ return _rebuild_code(root, changed_paths=None if initial else (changes or []),
+ no_cluster=True, acquire_lock=False, force=force)
+ argv = ["graphify", "extract", str(root), "--code-only", "--no-cluster"]
+ if force:
+ argv += ["--force", "--allow-partial"]
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(mainmod, "_refresh_stale_skills", lambda: None)
+ monkeypatch.setattr(mainmod.sys, "argv", argv)
+ try:
+ mainmod.main()
+ except SystemExit as exc:
+ return exc.code in (None, 0)
+ return True
+
+
+def _graph(root):
+ return load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8")))
+
+
+def _provider(root):
+ graph = _graph(root)
+ sites = [identity for identity, node in graph.nodes(data=True)
+ if node.get("source_file") == "Main.qml" and qml_metadata(node).get("kind") == "type_use"]
+ assert len(sites) == 1
+ assert qml_metadata(graph.nodes[sites[0]])["status"] == "resolved"
+ links = [data.get("_tgt", target) for source, target, data in graph.edges(data=True)
+ if data.get("_src", source) == sites[0] and data.get("relation") == "uses"]
+ assert len(links) == 1
+ return graph.nodes[links[0]]["source_file"]
+
+
+def _stamp(root):
+ return json.loads((root / "graphify-out" / state.QT_STATE_FILE).read_text(encoding="utf-8"))
+
+
+def _snapshot(root):
+ out = root / "graphify-out"
+ return {name: (out / name).read_bytes() for name in
+ ("graph.json", "manifest.json", state.QT_STATE_FILE)}
+
+
+@pytest.mark.parametrize("operation", ["watch", "extract"])
+def test_qml011_ac03_import_root_order_refreshes_unchanged_source_provider(tmp_path, monkeypatch, operation):
+ _project(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["first", "second"]')
+ assert _run(tmp_path, monkeypatch, operation, initial=True)
+ assert _provider(tmp_path) == "first/Public/Tools/Service.qml"
+ before = _stamp(tmp_path)
+ observed, real = [], extraction.extract
+
+ def observe(paths, **kwargs):
+ observed.extend(paths)
+ return real(paths, **kwargs)
+
+ monkeypatch.setattr(extraction, "extract", observe)
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["second", "first"]')
+ assert _run(tmp_path, monkeypatch, operation)
+ assert tmp_path / "Main.qml" in observed
+ assert _provider(tmp_path) == "second/Public/Tools/Service.qml"
+ assert _stamp(tmp_path)["fingerprint"] != before["fingerprint"]
+
+
+@pytest.mark.parametrize("operation", ["watch", "extract"])
+def test_qml011_ac03_package_version_change_reanalyzes_unchanged_corpus(tmp_path, monkeypatch, operation):
+ _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n")
+ assert _run(tmp_path, monkeypatch, operation, initial=True)
+ before = _stamp(tmp_path)
+ real_version, real_extract, observed = state.version, extraction.extract, []
+
+ def changed_version(package):
+ original = real_version(package)
+ return original + "+contract-probe" if package == "tree-sitter" else original
+
+ def observe(paths, **kwargs):
+ observed.extend(paths)
+ return real_extract(paths, **kwargs)
+
+ monkeypatch.setattr(state, "version", changed_version)
+ monkeypatch.setattr(extraction, "extract", observe)
+ assert _run(tmp_path, monkeypatch, operation)
+ assert tmp_path / "Main.qml" in observed
+ assert _stamp(tmp_path)["fingerprint"] != before["fingerprint"]
+ assert all(qml_metadata(node).get("contract_version") == 1 for _, node in _graph(tmp_path).nodes(data=True))
+
+
+@pytest.mark.parametrize("operation", ["watch", "extract"])
+def test_qml011_ac02_last_qt_source_deletion_cleans_facts_and_commits_nonqt_state(tmp_path, monkeypatch, operation):
+ source = _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n")
+ _write(tmp_path, "keep.py", "\n".join(f"def retained_{index}(): return {index}" for index in range(20)))
+ assert _run(tmp_path, monkeypatch, operation, initial=True)
+ assert _stamp(tmp_path)["has_qt"] is True
+ source.unlink()
+ assert _run(tmp_path, monkeypatch, operation, changes=[source])
+ graph = _graph(tmp_path)
+ assert not any(qml_metadata(node) for _, node in graph.nodes(data=True))
+ assert any(node.get("label") == "retained_0()" for _, node in graph.nodes(data=True))
+ assert _stamp(tmp_path)["has_qt"] is False
+ before = _snapshot(tmp_path)
+ assert _run(tmp_path, monkeypatch, operation)
+ assert _snapshot(tmp_path) == before
+
+
+@pytest.mark.parametrize("operation", ["watch", "extract"])
+@pytest.mark.parametrize("name,invalid", [
+ ("Broken.qml", "Item { property int value: }"),
+ ("broken.qrc", ']>'),
+ ("new.cmake", "if(ENABLE)\nqt_add_qml_module(app URI Public.Tools)\nendif()"),
+])
+def test_qml012_ac02_malformed_new_source_preserves_graph_manifest_and_stamp(tmp_path, monkeypatch, operation, name, invalid):
+ _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n")
+ assert _run(tmp_path, monkeypatch, operation, initial=True)
+ before = _snapshot(tmp_path)
+ source = _write(tmp_path, name, invalid)
+ assert not _run(tmp_path, monkeypatch, operation, changes=[source], force=True)
+ assert _snapshot(tmp_path) == before
+
+
+@pytest.mark.parametrize("operation", ["watch", "extract"])
+@pytest.mark.parametrize("config", ["not JSON", '["../outside"]', '["/absolute"]'])
+def test_qml012_ac02_invalid_import_configuration_preserves_prior_stamp(tmp_path, monkeypatch, operation, config):
+ _write(tmp_path, "Main.qml", "QtObject { property int value: 1 }\n")
+ assert _run(tmp_path, monkeypatch, operation, initial=True)
+ before = _snapshot(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", config)
+ assert not _run(tmp_path, monkeypatch, operation, force=True)
+ assert _snapshot(tmp_path) == before
diff --git a/tests/test_qt_construction_authority.py b/tests/test_qt_construction_authority.py
new file mode 100644
index 0000000000..c3ed238634
--- /dev/null
+++ b/tests/test_qt_construction_authority.py
@@ -0,0 +1,143 @@
+"""REQ-QML-017: construction ownership needs non-widget QObject ancestry."""
+from __future__ import annotations
+
+import copy
+
+import pytest
+
+from graphify.extractors.qml import extract_qml
+from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata
+from graphify.qml_resolution_types import qml_metadata
+from graphify.qt_qml_access_index import QtQmlAccessIndex
+from tests.test_qt_receiver_boundaries import persisted
+
+
+QML = ('import QtQuick\nimport Public.Tools 1.0\nItem { '
+ 'property Owned child: Owned { objectName: "child"; property int count: 1 } }')
+
+
+def native(root, declarations, *, extra=None):
+ sources = {"backend.h": declarations, "CMakeLists.txt":
+ 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)'}
+ return persisted(root, 'root->findChild("child");', QML,
+ extra={**sources, **(extra or {})})
+
+
+@pytest.mark.parametrize("declarations", [
+ 'class Backend : public QWidget { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+ 'class Base : public QWidget { Q_OBJECT }; '
+ 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+ 'class Backend : public QQuickWidget { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+ 'class Backend : public ExternalBase { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+ 'namespace Other { class QObject : public QWidget { Q_OBJECT }; } '
+ 'class Backend : public Other::QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+ 'class QObject : public QWidget { Q_OBJECT }; '
+ 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+])
+def test_req_qml017_ac02_widget_and_unknown_ancestry_cannot_authorize_child_tree(tmp_path, declarations):
+ result, site, links = native(tmp_path, declarations)
+ reason = "find_child_type_unsupported" if declarations.startswith("class QObject :") else "native_construction_ancestry_unestablished"
+ assert not links and qt_metadata(site)["reason"] == reason
+ # The registration/declaration remains observed; rejecting construction
+ # authority must not erase its source fact or invent a substitute endpoint.
+ assert any(qt_metadata(node).get("kind") == "registration" for node in result["nodes"])
+ child = next(node for node in result["nodes"] if qml_metadata(node).get("type_name") == "Owned")
+ assert qml_metadata(child)["construction_parent_scope_key"]
+
+
+@pytest.mark.parametrize("declarations", [
+ 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+ 'class Base : public QObject { Q_OBJECT }; '
+ 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+ 'namespace Public { class Base : public QObject { Q_OBJECT }; '
+ 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }',
+ 'class Base : public QObject { Q_OBJECT }; '
+ 'namespace Public { class Backend : public ::Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }',
+ 'class Interface {}; class Backend : public QObject, public Interface { Q_OBJECT QML_NAMED_ELEMENT(Owned) };',
+])
+def test_req_qml017_ac04_direct_and_source_defined_qobject_chain_survives_publication(tmp_path, declarations):
+ result, site, links = native(tmp_path, declarations)
+ assert qt_metadata(site)["status"] == "resolved" and len(links) == 1
+ child = next(node for node in result["nodes"] if node["id"] == links[0]["target"])
+ assert qml_metadata(child)["type_name"] == "Owned" and child["source_file"] == "Main.qml"
+
+
+def test_req_qml017_ac04_exact_namespace_ancestry_does_not_choose_other_file_basename(tmp_path):
+ declarations = ('namespace Public { class Base : public QObject { Q_OBJECT }; '
+ 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }')
+ result, site, links = native(tmp_path, declarations,
+ extra={"other.h": 'class Base : public QWidget { Q_OBJECT };'})
+ assert qt_metadata(site)["status"] == "resolved" and len(links) == 1
+ bases = [qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "class"
+ and qt_metadata(node).get("class_name") in {"Base", "Public::Base"}]
+ assert len(bases) == 2 and len({md["class_id"] for md in bases}) == 2
+
+
+def test_req_qml017_ac04_distinct_same_file_qualified_bodies_supply_only_exact_ancestry(tmp_path):
+ declarations = ('class Base : public QWidget { Q_OBJECT }; '
+ 'namespace Public { class Base : public QObject { Q_OBJECT }; '
+ 'class Backend : public Base { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }')
+ result, site, links = native(tmp_path, declarations)
+ assert qt_metadata(site)["status"] == "resolved" and len(links) == 1
+ bases = [qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "class"
+ and qt_metadata(node).get("class_name") in {"Base", "Public::Base"}]
+ assert len(bases) == 2 and len({md["class_id"] for md in bases}) == 2
+ assert {md["class_name"]: md["canonical_base_names"] for md in bases} == {
+ "Base": ["QWidget"], "Public::Base": ["QObject"]}
+ assert not any(node.get("metadata", {}).get("cpp_class", {}).get("ambiguous") for node in result["nodes"])
+
+
+@pytest.mark.parametrize("alias", ["using QObject = QWidget;", "using QObject = QQuickWidget;"])
+def test_req_qml017_ac02_widget_alias_cannot_inherit_global_qobject_authority(tmp_path, alias):
+ declarations = ('namespace Public { ' + alias
+ + ' class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) }; }')
+ result, site, links = native(tmp_path, declarations)
+ assert not links and qt_metadata(site)["reason"] == "native_construction_ancestry_unestablished"
+ md = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "class")
+ assert md["bases"] == ["QObject"] and md["canonical_base_names"] != ["QObject"]
+
+
+@pytest.mark.parametrize("corruption", ["definition", "status", "origin", "identity", "base", "canonical_status", "canonical_missing"])
+def test_req_qml017_ac02_native_ancestry_reads_only_accepted_complete_source_facts(tmp_path, corruption):
+ result, _, _ = native(tmp_path, 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Owned) };')
+ nodes, edges = copy.deepcopy(result["nodes"]), copy.deepcopy(result["edges"])
+ declaration = next(node for node in nodes if qt_metadata(node).get("kind") == "class")
+ md = qt_metadata(declaration)
+ md.pop("raw_values", None)
+ if corruption == "origin":
+ declaration["_origin"] = "llm"
+ else:
+ key, value = {"definition": ("is_definition", False), "status": ("status", "unavailable"),
+ "identity": ("class_id", "unaccepted"), "base": ("canonical_base_names", ["Unknown"]),
+ "canonical_status": ("canonical_base_status", "unavailable"),
+ "canonical_missing": ("canonical_base_names", None)}[corruption]
+ declaration["metadata"]["qt"] = encode_qt({**md, key: value})
+ before = copy.deepcopy((nodes, edges))
+ index = QtQmlAccessIndex(nodes, edges, root=tmp_path)
+ root = next(node for node in nodes if qml_metadata(node).get("kind") == "object"
+ and not qml_metadata(node).get("parent_scope_key"))
+ assert index.find_child(root["id"], "child").target_id is None
+ assert (nodes, edges) == before
+
+
+@pytest.mark.parametrize("count", [50, 51])
+def test_req_qml017_ac04_parent_mutation_spans_are_bounded_original_bytes(tmp_path, count):
+ source = ('import QtQuick\r\nItem { id: left; Item { id: right } function change() { '
+ + 'left.parent = right; ' * count + '} }').encode()
+ path = tmp_path / "Main.qml"
+ path.write_bytes(source)
+ result = extract_qml(path, root=tmp_path)
+ if count == 51:
+ assert result["nodes"] == [] and result["edges"] == []
+ assert result["error"].startswith("QML_LIMIT")
+ assert result["diagnostics"][0]["code"] == "QML_LIMIT"
+ else:
+ assert not result.get("error")
+ objects = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "object"]
+ assert objects and all(md.get("construction_parent_dynamic") for md in objects)
+ for md in objects:
+ spans = md["construction_parent_mutation_spans"]
+ assert len(spans) == 50
+ for span in spans:
+ assert source[span["start_byte"]:span["end_byte"]] == b"left.parent = right"
+ assert span["start_row"] == source[:span["start_byte"]].count(b"\n")
diff --git a/tests/test_qt_constructor_ownership.py b/tests/test_qt_constructor_ownership.py
new file mode 100644
index 0000000000..8c8984b9ba
--- /dev/null
+++ b/tests/test_qt_constructor_ownership.py
@@ -0,0 +1,178 @@
+"""REQ-QML-008/016/017: constructor ownership reaches durable Qt consumers."""
+import json
+
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.cluster import cluster
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.serve import _query_graph_text
+from tests.qt_analysis_helpers import analysis, sites
+
+
+HEADER = '''class Backend : public QObject {
+ Q_OBJECT
+public:
+ explicit Backend(QObject *parent = nullptr);
+signals:
+ void changed();
+};
+'''
+BODY = '''#include "backend.hpp"
+Backend::Backend(QObject *parent) : QObject(parent) {
+ QQmlApplicationEngine engine;
+ engine.load("qrc:/App/Main.qml");
+ QObject *root = engine.rootObjects().first();
+ root->setProperty("value", 2);
+ emit changed();
+}
+'''
+
+
+def fixture(root, header=HEADER, body=BODY):
+ """An accepted literal resource supplies reverse-QML targets without executing Qt."""
+ return analysis(root, {"backend.hpp": header, "backend.cpp": body,
+ "Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "app.qrc": 'Main.qml'})
+
+
+def constructor(result):
+ candidates = [node for node in result["nodes"] if node.get("_callable") and not node.get("_callable_class")
+ and (node.get("definition_file") == "backend.cpp" or node.get("source_file") == "backend.cpp")]
+ assert len(candidates) == 1
+ return candidates[0]
+
+
+@pytest.mark.parametrize("namespace", [False, True])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml016_ac01_and_qml017_ac02_constructor_owns_emission_and_write_after_json_reload(tmp_path, namespace, directed):
+ """Original constructor identity owns every site through assembly, publication and real consumers."""
+ header = f"namespace Shared {{\n{HEADER}}}\n" if namespace else HEADER
+ body = BODY.replace("Backend::Backend", "Shared::Backend::Backend") if namespace else BODY
+ result = fixture(tmp_path, header, body)
+ method = constructor(result)
+ owned = sites(result, "qml_load") + sites(result, "qml_root") + sites(result, "qml_access") + sites(result, "emission")
+ assert len(owned) == 4
+ assert all(qt_metadata(node)["owner_id"] == method["id"] and qt_metadata(node)["status"] == "resolved" for node in owned)
+ emission = sites(result, "emission")[0]
+ signal = qt_metadata(emission)["target_id"]
+ original = (tmp_path / "backend.cpp").read_bytes()
+ span = qt_metadata(emission)["span"]
+ assert original[span["start_byte"]:span["end_byte"]] == b"changed()"
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, cluster(graph), str(path))
+ reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ assert all(reloaded.has_edge(method["id"], node["id"]) for node in owned)
+ assert all(qt_metadata(reloaded.nodes[node["id"]])["owner_id"] == method["id"] for node in owned)
+ assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(emission["id"], data=True))
+ assert method["id"] in {hit.node_id for hit in affected_nodes(reloaded, signal, depth=3)}
+ answer = _query_graph_text(reloaded, "Backend changed", token_budget=4000)
+ assert "backend.cpp" in answer and "Qt emission: changed" in answer
+
+
+def test_req_qml008_ac02_constructor_spans_use_original_bom_crlf_unicode_bytes(tmp_path):
+ """No normalized-newline or display-string offset may replace producer byte positions."""
+ body = ("\ufeff// caf\u00e9 \u96ea\n" + BODY).replace("\n", "\r\n")
+ result = fixture(tmp_path, body=body)
+ method = constructor(result)
+ assert method["definition_file"] == "backend.cpp" and method["definition_location"] == "L3"
+ original = (tmp_path / "backend.cpp").read_bytes()
+ assert original == body.encode("utf-8")
+ for node in sites(result, "emission") + sites(result, "qml_access"):
+ metadata = qt_metadata(node)
+ assert metadata["owner_id"] == method["id"]
+ span = metadata["span"]
+ expected = b"changed()" if metadata["kind"] == "emission" else b'root->setProperty("value", 2)'
+ assert original[span["start_byte"]:span["end_byte"]] == expected
+
+
+@pytest.mark.parametrize("control", ["duplicate_body", "foreign_namespace", "duplicate_signature"])
+def test_req_qml016_ac04_constructor_conflicts_cannot_invent_a_canonical_owner(tmp_path, control):
+ """Duplicate classes or signatures remain visible without canonical native authority."""
+ header, body = HEADER, BODY
+ if control == "foreign_namespace":
+ header = f"namespace Other {{\n{HEADER}}}\n"
+ body = BODY.replace("Backend::Backend", "Foreign::Backend::Backend")
+ elif control == "duplicate_signature":
+ header = HEADER.replace("signals:", " explicit Backend(QObject *owner);\nsignals:")
+ sources = {"backend.hpp": header, "backend.cpp": body}
+ if control == "duplicate_body":
+ sources["duplicate.hpp"] = header
+ sources.update({"Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "app.qrc": 'Main.qml'})
+ result = analysis(tmp_path, sources)
+ method = constructor(result)
+ implementation = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp")
+ assert qt_metadata(implementation)["class_id"] == ""
+ assert not any(edge["target"] == method["id"] and edge["relation"] == "method" for edge in result["edges"])
+ emission = qt_metadata(sites(result, "emission")[0])
+ assert emission["owner_id"] == method["id"] and emission["status"] != "resolved" and emission["target_id"] == ""
+ # Independently proved source callables and literal resource handles do not
+ # need native class authority to own a QML write in the same body.
+ access = qt_metadata(sites(result, "qml_access")[0])
+ assert access["owner_id"] == method["id"] and access["status"] == "resolved"
+
+
+def test_req_qml008_ac02_delegating_overload_keeps_exact_native_site_owner(tmp_path):
+ """Distinct signatures authorize the zero-argument body, without a guessed delegation call."""
+ header = HEADER.replace(" explicit Backend", " Backend();\n explicit Backend")
+ body = BODY.replace("Backend::Backend(QObject *parent) : QObject(parent)",
+ "Backend::Backend() : Backend(nullptr)")
+ result = fixture(tmp_path, header, body)
+ method = constructor(result)
+ implementation = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp")
+ assert qt_metadata(implementation)["class_id"]
+ assert any(edge["target"] == method["id"] and edge["relation"] == "method" for edge in result["edges"])
+ emission = qt_metadata(sites(result, "emission")[0])
+ assert emission["owner_id"] == method["id"] and emission["status"] == "resolved" and emission["target_id"]
+ other_constructors = {node["id"] for node in result["nodes"]
+ if node.get("metadata", {}).get("cpp_constructor") and node["id"] != method["id"]}
+ assert not any(edge["source"] == method["id"] and edge["target"] in other_constructors
+ and edge["relation"] == "calls" for edge in result["edges"])
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml016_ac04_and_qml017_ac04_constructor_updates_remove_stale_sites_and_preserve_failures(tmp_path, monkeypatch, operation):
+ """Real cold/warm, CLI/watch, edit/removal and failed publication keep exact source ownership."""
+ from tests.test_qt_final_incremental_parity import clean, normalized, run
+
+ fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cache = tmp_path / ".comparison-cache"
+ cold = clean(tmp_path, cache)
+ assert normalized(clean(tmp_path, cache)) == normalized(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ paths = [tmp_path / "backend.hpp", tmp_path / "backend.cpp"]
+ for path in paths:
+ path.write_text(path.read_text(encoding="utf-8").replace("parent", "owner"), encoding="utf-8", newline="")
+ changed = run(tmp_path, monkeypatch, operation, paths)
+ assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".edited-cache"))
+ emissions = [identity for identity, data in changed.nodes(data=True) if qt_metadata(data).get("kind") == "emission"]
+ assert len(emissions) == 1 and qt_metadata(changed.nodes[emissions[0]])["owner_id"]
+ cpp = paths[1]
+ cpp.write_text(cpp.read_text(encoding="utf-8").replace(" emit changed();\n", ""), encoding="utf-8", newline="")
+ removed = run(tmp_path, monkeypatch, operation, [cpp])
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ assert not any(qt_metadata(data).get("kind") == "emission" for _, data in removed.nodes(data=True))
+ output = tmp_path / "graphify-out"
+ retained = {name: (output / name).read_bytes() for name in ("graph.json", "manifest.json", ".qt_analysis.json")}
+ valid = cpp.read_bytes()
+ cpp.write_bytes(valid + b"\nvoid damaged( {\n")
+ if operation == "manual":
+ with pytest.raises(SystemExit) as failure:
+ run(tmp_path, monkeypatch, operation, [cpp])
+ assert failure.value.code == 1
+ else:
+ from graphify.watch import _rebuild_code
+ assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True)
+ assert retained == {name: (output / name).read_bytes() for name in retained}
+ cpp.write_bytes(valid)
+ corrected = run(tmp_path, monkeypatch, operation, [cpp])
+ assert normalized(corrected) == normalized(removed)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(corrected)
diff --git a/tests/test_qt_context_callback_integrity.py b/tests/test_qt_context_callback_integrity.py
new file mode 100644
index 0000000000..d8fdf6fad8
--- /dev/null
+++ b/tests/test_qt_context_callback_integrity.py
@@ -0,0 +1,104 @@
+"""REQ-QML-018-AC04/AC06: subscriptions retain their exact source callback."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import encode_metadata, qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.paths import load_node_link_graph
+from graphify.qt_qml_projection import allows_qt_qml_edge
+from tests.qt_adoption_fixture import QML, sources
+from tests.qt_analysis_helpers import analysis
+
+
+def callback_view(tmp_path, result, stage, form):
+ """Exercise raw facts and both graph orientations after real serialization."""
+ if stage == "raw":
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ by_id = {node["id"]: node for node in nodes}
+ else:
+ graph = build_from_json(result, root=tmp_path, directed=stage == "directed")
+ path = tmp_path / "callbacks.json"
+ assert to_json(graph, {}, str(path), force=True)
+ restored = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ by_id = restored.nodes
+ edges = [{**attrs, "source": attrs.get("_src", source), "target": attrs.get("_tgt", target)}
+ for source, target, attrs in restored.edges(data=True)]
+ edge = next(item for item in edges if item.get("context") == "qt_context_subscription"
+ and qt_metadata(by_id[item["source"]]).get("subscription_form") == form)
+ source_id, target_id = edge["source"], edge["target"]
+ def accepted():
+ return allows_qt_qml_edge(by_id[source_id], by_id[target_id], edge,
+ source_id=source_id, target_id=target_id, nodes=by_id)
+ assert accepted()
+ return by_id, by_id[source_id], accepted
+
+
+@pytest.mark.parametrize("stage", ["raw", "undirected", "directed"])
+@pytest.mark.parametrize("form", ["signal.connect", "Connections"])
+def test_req_qml018_ac04_ac06_callback_target_cannot_be_substituted(tmp_path, stage, form):
+ """Changing only the derived target to a real sibling function must invalidate the bridge."""
+ nodes, subscription, accepted = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path)), stage, form)
+ replacement = next(nid for nid, node in nodes.items() if qml_metadata(node).get("kind") == "function"
+ and qml_metadata(node).get("raw_name") == "run")
+ update_qt(subscription, handler_target_id=replacement)
+ assert not accepted()
+
+
+@pytest.mark.parametrize("stage", ["raw", "undirected", "directed"])
+def test_req_qml018_ac04_ac06_local_callback_precedes_same_named_qml_function(tmp_path, stage):
+ """The captured local binder remains accepted; a same-name QML function cannot replace it."""
+ qml = QML.replace("function run() {", "function run() { function handleReady() {}")
+ nodes, subscription, accepted = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path, qml=qml)), stage, "signal.connect")
+ assert qml_metadata(nodes[qt_metadata(subscription)["handler_target_id"]])["kind"] == "js_function"
+ replacement = next(nid for nid, node in nodes.items() if qml_metadata(node).get("kind") == "function"
+ and qml_metadata(node).get("raw_name") == "handleReady")
+ update_qt(subscription, handler_target_id=replacement)
+ assert not accepted()
+
+
+@pytest.mark.parametrize("corruption", ["reference", "arity", "lexical", "name", "scope", "span", "handler_span", "form", "owner"])
+def test_req_qml018_ac06_callback_borrowed_provenance_is_revalidated(tmp_path, corruption):
+ """Accepted callback IDs do not repair changed call, declaration, scope or site ownership."""
+ nodes, subscription, accepted = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path)), "raw", "signal.connect")
+ md = qt_metadata(subscription)
+ call, handler = nodes[md["owner_id"]], nodes[md["handler_target_id"]]
+ if corruption in {"form", "owner"}:
+ update_qt(subscription, **({"subscription_form": "Connections"} if corruption == "form" else {"owner_id": md["handler_target_id"]}))
+ elif corruption in {"span", "handler_span"}:
+ span = md["span"] if corruption == "span" else qml_metadata(handler)["span"]
+ span["start_byte"] += 1 if corruption == "span" else 1_000_000
+ if corruption == "handler_span":
+ span["end_byte"] += 1_000_000
+ fields = {key: value for key, value in qml_metadata(handler).items() if key != "raw_values"}
+ fields["span"] = span
+ handler["metadata"]["qml"] = encode_metadata(fields)
+ else:
+ update_qt(subscription, span=span)
+ else:
+ chosen = handler if corruption in {"name", "scope"} else call
+ fields = {key: value for key, value in qml_metadata(chosen).items() if key != "raw_values"}
+ fields.update({"reference": {"callback_reference": "run"}, "arity": {"callback_argument_count": 2},
+ "lexical": {"callback_lexical_shadowed": True, "callback_lexical_target_id": ""},
+ "name": {"raw_name": "unrelated"}, "scope": {"object_scope_key": "unrelated"}}[corruption])
+ chosen["metadata"]["qml"] = encode_metadata(fields)
+ assert not accepted()
+
+
+@pytest.mark.parametrize("stage", ["raw", "undirected", "directed"])
+@pytest.mark.parametrize("callback", ["qualified", "own_scope"])
+def test_req_qml018_ac04_callback_lookup_retains_object_scope(tmp_path, stage, callback):
+ """A same-name root function cannot displace an own member or qualified ID member."""
+ use = "backend.service.ready.connect(" + ("receiver.handleReady" if callback == "qualified" else "handleReady") + ")"
+ child = " property QtObject nested: QtObject { id: receiver; function handleReady() {}\n"
+ qml = "import QtQml\nQtObject {\n function handleReady() {}\n" + child
+ qml += (" }\n function run() { " + use + " }\n" if callback == "qualified" else " function run() { " + use + " }\n }\n") + "}\n"
+ nodes, subscription, _ = callback_view(tmp_path, analysis(tmp_path, sources(tmp_path, qml=qml)), stage, "signal.connect")
+ selected = qml_metadata(nodes[qt_metadata(subscription)["handler_target_id"]])
+ receiver = next(qml_metadata(node) for node in nodes.values() if qml_metadata(node).get("object_id") == "receiver")
+ assert selected["object_scope_key"] == receiver["object_scope_key"]
diff --git a/tests/test_qt_context_implicit_subscriptions.py b/tests/test_qt_context_implicit_subscriptions.py
new file mode 100644
index 0000000000..e93bdcd6ff
--- /dev/null
+++ b/tests/test_qt_context_implicit_subscriptions.py
@@ -0,0 +1,36 @@
+"""REQ-QML-018-AC04: native legacy parameters precede nested subscription lookup."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_adoption_fixture import NATIVE, sources
+from tests.qt_analysis_helpers import analysis, sites
+
+
+@pytest.mark.parametrize("parameter", ["backend", "handleReady"])
+@pytest.mark.parametrize("style", ["legacy", "function", "arrow"])
+def test_req_qml018_ac04_native_parameters_own_nested_provider_and_callback_lookup(tmp_path, parameter, style):
+ """Legacy native binders shadow providers/callbacks; explicit handlers retain their own formals."""
+ body = "{ backend.service.ready.connect(handleReady) }"
+ handler = {"legacy": "onReady: " + body, "function": "function onReady(other) " + body,
+ "arrow": "onReady: (other) => " + body}[style]
+ qml = "import QtQml\nQtObject { function handleReady() {} property Connections sub: Connections { target: backend.service; " + handler + " } }"
+ native = NATIVE.replace("void ready();", "void ready(int " + parameter + ");")
+ result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native))
+ assert len(sites(result, "context_subscription")) == (1 if style == "legacy" else 2)
+ assert len(sites(result, "context_handler_endpoint")) == (1 if style == "legacy" else 2)
+ if style == "legacy":
+ assert {qt_metadata(node)["subscription_form"] for node in sites(result, "context_subscription")} == {"Connections"}
+
+
+def test_req_qml018_ac04_nearer_local_callback_keeps_explicit_authority(tmp_path):
+ """A local JS function inside a legacy handler precedes its same-name native parameter."""
+ qml = "import QtQml\nQtObject { property Connections sub: Connections { target: backend.service; onReady: { function handleReady() {} backend.service.ready.connect(handleReady) } } }"
+ native = NATIVE.replace("void ready();", "void ready(int handleReady);")
+ result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native))
+ assert len(sites(result, "context_subscription")) == 2
+ nested = next(node for node in sites(result, "context_subscription") if qt_metadata(node)["subscription_form"] == "signal.connect")
+ by_id = {node["id"]: node for node in result["nodes"]}
+ assert qml_metadata(by_id[qt_metadata(nested)["handler_target_id"]])["kind"] == "js_function"
diff --git a/tests/test_qt_cpp_adoption_syntax.py b/tests/test_qt_cpp_adoption_syntax.py
new file mode 100644
index 0000000000..bb571453a1
--- /dev/null
+++ b/tests/test_qt_cpp_adoption_syntax.py
@@ -0,0 +1,189 @@
+"""REQ-QML-018-AC02 valid Qt/C++ recovery keeps original bytes and safety failures."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extract import extract, extract_cpp
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_syntax import QtCppError, normalize_qt_cpp, read_cpp, source_span, walk
+
+
+def write(root, source):
+ path = root / "adapter.cpp"
+ path.write_bytes(source)
+ return path
+
+
+def facade(root, path):
+ result = extract([path], root=root, cache_root=root / ".probe-cache", parallel=False)
+ assert not result["failed_sources"] and not result["qt_failures"] and not result["qml_failures"]
+ return result
+
+
+@pytest.mark.parametrize("parameter", [
+ b"const Value &value = {}", b"Value value = { /* empty */ }", b"Value value = {\r\n}",
+])
+def test_req_qml018_ac02_empty_parameter_default_recovers_exact_declared_member(tmp_path, parameter):
+ """A valid empty default has no evaluated expression to erase or fabricate."""
+ source = b"class Value {}; class Adapter : public QObject { Q_OBJECT public: void accept(" + parameter + b"); };"
+ path = write(tmp_path, source)
+ unit = read_cpp(path, tmp_path)
+ assert not unit.root.has_error and unit.source == source
+ assert len(unit.parsed_source) == len(source)
+ assert unit.parsed_source.count(b"\r\n") == source.count(b"\r\n")
+ result = facade(tmp_path, path)
+ members = [qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("raw_name") == "accept"
+ and qt_metadata(node).get("kind") == "member"]
+ assert len(members) == 1 and members[0]["generic_target_id"]
+ assert members[0]["parameter_names"] == ["value"]
+ assert members[0]["parameter_types"] == (["const Value&"] if parameter.startswith(b"const") else ["Value"])
+ span = members[0]["span"]
+ assert source[span["start_byte"]:span["end_byte"]] == b"void accept(" + parameter + b");"
+ assert not extract_cpp(path).get("parse_errors")
+
+
+@pytest.mark.parametrize("semicolon", [b"", b";"])
+def test_req_qml018_ac02_unused_arguments_keep_evaluated_nested_calls(tmp_path, semicolon):
+ """Qt's supplied semicolon is parser syntax; argument calls remain real calls."""
+ source = (b"int helper(){ return 1; } int second(){ return 2; }\n"
+ b"class Adapter : public QObject { Q_OBJECT public:\n"
+ b" void accept(int value) {\n Q_UNUSED(helper())" + semicolon + b"\n"
+ b" if (value) Q_UNUSED((second(), value))" + semicolon + b"\n }\n};\n")
+ path = write(tmp_path, source)
+ unit = read_cpp(path, tmp_path)
+ assert unit.source == source and len(unit.parsed_source) == len(source)
+ calls = [node for node in walk(unit.root) if node.type == "call_expression"]
+ assert len(calls) == 2
+ assert {unit.field(node, "function") for node in calls} == {"helper", "second"}
+ assert {unit.source[node.start_byte:node.end_byte] for node in calls} == {b"helper()", b"second()"}
+ result = facade(tmp_path, path)
+ by_label = {node["label"]: node["id"] for node in result["nodes"]}
+ member = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "member"
+ and qt_metadata(node).get("raw_name") == "accept")
+ retained = [edge for edge in result["edges"] if edge["relation"] == "calls"
+ and edge["source"] == member["generic_target_id"]
+ and edge["target"] in {by_label["helper()"], by_label["second()"]}]
+ assert {edge["source_location"] for edge in retained} == {"L4", "L5"}
+ assert not any(edge.get("context") == "qt_signal_emit" for edge in result["edges"])
+ assert all(node.get("label") != "Q_UNUSED()" for node in result["nodes"])
+ direct = extract_cpp(path)
+ assert not direct.get("parse_errors")
+ assert not any(call["callee"] == "Q_UNUSED" for call in direct.get("raw_calls", []))
+
+
+def test_req_qml018_ac02_bom_unicode_crlf_preserve_original_member_and_call_offsets(tmp_path):
+ """Recovery changes only syntax bytes; BOM, Unicode and CRLF remain in place."""
+ source = (b"\xef\xbb\xbf" + "// café ☃\r\n".encode()
+ + b"int helper(){ return 1; }\r\nclass Value {};\r\n"
+ b"class Adapter : public QObject { Q_OBJECT public:\r\n"
+ b" void accept(const Value &value = {}) {\r\n Q_UNUSED(helper())\r\n }\r\n};\r\n")
+ path = write(tmp_path, source)
+ unit = read_cpp(path, tmp_path)
+ assert unit.source == source and unit.parsed_source.startswith(b"\xef\xbb\xbf")
+ assert [offset for offset, byte in enumerate(source) if byte in (10, 13)] == [
+ offset for offset, byte in enumerate(unit.parsed_source) if byte in (10, 13)
+ ]
+ call = next(node for node in walk(unit.root) if node.type == "call_expression")
+ offset = source.index(b"helper()", source.index(b"Q_UNUSED"))
+ assert source_span(source, call.start_byte, call.end_byte) == source_span(source, offset, offset + 8)
+ assert (call.start_point.row, call.start_point.column) == (5, 10)
+ result = facade(tmp_path, path)
+ member = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("raw_name") == "accept")
+ assert member["span"]["start_row"] == 4
+ assert source[member["span"]["start_byte"]:member["span"]["end_byte"]].startswith(b"void accept(const Value &value = {})")
+
+
+@pytest.mark.parametrize("argument", [b'""', b'"Q_UNUSED(fake())"', b'R"tag(x)tag"', b"1"])
+def test_req_qml018_ac02_literal_unused_argument_is_a_real_expression(tmp_path, argument):
+ """Quoted/raw literals are expression content, while their text stays inert."""
+ source = b"void use(){ Q_UNUSED(" + argument + b") }"
+ unit = read_cpp(write(tmp_path, source), tmp_path)
+ assert unit.source == source and len(unit.parsed_source) == len(source)
+ assert argument in unit.parsed_source and not unit.root.has_error
+ assert not any(node.type == "call_expression" for node in walk(unit.root))
+ result = facade(tmp_path, unit.path)
+ assert not any(edge["relation"] == "calls" for edge in result["edges"])
+
+
+@pytest.mark.parametrize("argument", [b"", b"/* no expression */"])
+def test_req_qml018_ac02_empty_or_comment_only_unused_argument_is_not_recovered(tmp_path, argument):
+ """A missing argument cannot become an invented cast value or valid statement."""
+ source = b"void use(){ Q_UNUSED(" + argument + b") }"
+ assert normalize_qt_cpp(source) is None
+ with pytest.raises(QtCppError) as failure:
+ read_cpp(write(tmp_path, source), tmp_path)
+ assert failure.value.code == "QT_CPP_SYNTAX"
+
+
+@pytest.mark.parametrize("source", [
+ b"class A { void f(const T &value = {); };",
+ b"class A { void f(const T &value = {} ; };",
+ b"class A { void f(const T &value = {1}); };",
+ b"void f(int value) { Q_UNUSED(helper(value) }",
+ b"void f(int value) { Q_UNUSED(value) int broken = ; }",
+])
+def test_req_qml018_ac02_damaged_or_unsupported_syntax_still_rejects(tmp_path, source):
+ """Recovering two valid forms must not hide unrelated or nonempty-default errors."""
+ path = write(tmp_path, source)
+ with pytest.raises(QtCppError) as failure:
+ read_cpp(path, tmp_path)
+ assert failure.value.code == "QT_CPP_SYNTAX"
+
+
+@pytest.mark.parametrize("source", [
+ b'// Q_UNUSED(helper())\n/* const T &x = {} */\nconst char *s="Q_UNUSED(helper())";',
+ b'const char *s=R"tag(Q_UNUSED(helper()) const T &x = {})tag";',
+ b"#define BODY(value) \\\n Q_UNUSED(helper(value))\nclass A {};",
+ b"#define Q_UNUSED(value) value\nvoid f(int value){ Q_UNUSED(value); }",
+ b"#undef Q_UNUSED\nvoid f(int value){ Q_UNUSED(value); }",
+ b"void Q_UNUSED(int value) {} void f(){ Q_UNUSED(1); }",
+ b"void f(){ object.Q_UNUSED(1); other::Q_UNUSED(2); }",
+ b"int helper(); int f(){ return Q_UNUSED(helper()); }",
+ b"int helper(); void f(){ int value = Q_UNUSED(helper()); }",
+ b"void f(){ int value = {}; }",
+])
+def test_req_qml018_ac02_inert_definitions_and_ordinary_contexts_are_not_masked(source):
+ """Lexical exclusion and expression-statement ownership guard false recovery."""
+ assert normalize_qt_cpp(source) is None
+
+
+def test_req_qml018_ac02_ordinary_cpp_default_and_named_function_keep_canonical_ids(tmp_path):
+ """The core C++ facade retains ordinary symbols and calls without Qt facts."""
+ source = b"class Value {}; void accept(const Value &value = {}) {}\nvoid helper(){}\nvoid use(){helper();}\n"
+ path = write(tmp_path, source)
+ direct = extract_cpp(path)
+ assert not direct.get("parse_errors")
+ result = facade(tmp_path, path)
+ nodes = {node["label"]: node for node in result["nodes"]}
+ assert nodes["accept()"]["id"] == "adapter_accept"
+ assert nodes["helper()"]["id"] == "adapter_helper" and nodes["use()"]["id"] == "adapter_use"
+ assert not any(qt_metadata(node) for node in result["nodes"])
+ assert any(edge["relation"] == "calls" and edge["source"] == "adapter_use"
+ and edge["target"] == "adapter_helper" for edge in result["edges"])
+
+
+@pytest.mark.parametrize("number", [b"16'384", b"0xAB'CD", b"0b10'01", b"1.2'34e1'0"])
+def test_req_qml018_ac02_numeric_separator_keeps_later_signal_ownership(tmp_path, number):
+ """Numeric apostrophes cannot mask the remaining class as a character literal."""
+ source = (b"// caf\xc3\xa9\r\nclass Adapter : public QObject { Q_OBJECT public:\r\n"
+ b" static constexpr auto limit = " + number + b";\r\nsignals:\r\n"
+ b" void ready();\r\n};\r\n")
+ unit = read_cpp(write(tmp_path, source), tmp_path)
+ assert unit.source == source and number in unit.code
+ assert len(unit.code) == len(unit.parsed_source) == len(source)
+ result = facade(tmp_path, unit.path)
+ member = next(qt_metadata(node) for node in result["nodes"]
+ if qt_metadata(node).get("raw_name") == "ready")
+ assert member["roles"] == ["signal"]
+ span = member["span"]
+ assert source[span["start_byte"]:span["end_byte"]] == b"void ready();"
+ assert not extract_cpp(unit.path).get("parse_errors")
+
+
+@pytest.mark.parametrize("number", [b"16''384", b"16'", b"0x'AB"])
+def test_req_qml018_ac02_malformed_numeric_separators_remain_rejected(tmp_path, number):
+ """Lexical number handling leaves the original token for parser validation."""
+ source = b"class A : public QObject { Q_OBJECT public: int limit = " + number + b"; signals: void ready(); };"
+ with pytest.raises(QtCppError) as failure:
+ read_cpp(write(tmp_path, source), tmp_path)
+ assert failure.value.code == "QT_CPP_SYNTAX"
diff --git a/tests/test_qt_cpp_context_ownership.py b/tests/test_qt_cpp_context_ownership.py
new file mode 100644
index 0000000000..5f7efccde8
--- /dev/null
+++ b/tests/test_qt_cpp_context_ownership.py
@@ -0,0 +1,145 @@
+"""REQ-QML-008-AC02/016-AC01 retain native ownership over borrowed header facts."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.qt_qml_pipeline import resolve_qt_qml
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_cpp_definition_ownership import CPP, HEADER, canonical_run
+
+
+def accepted_context(root, *, qualified, conflict=False):
+ """Use real finalized generic identities and unchanged accepted header overlays.
+
+ Only implementation overlays are being refreshed. Generic canonical nodes
+ remain accepted inputs, including their exact header/definition provenance.
+ """
+ cpp = CPP.replace("Backend::run", "Shared::Backend::run") if qualified else CPP
+ sources = {"backend.hpp": HEADER, "backend.cpp": cpp}
+ if conflict:
+ sources["competing.hpp"] = HEADER
+ complete = analysis(root, sources)
+ nodes = [node for node in complete["nodes"]
+ if not (node.get("source_file") == "backend.cpp" and qt_metadata(node))]
+ identities = {node["id"] for node in nodes}
+ edges = [edge for edge in complete["edges"]
+ if edge["source"] in identities and edge["target"] in identities]
+ return complete, nodes, edges
+
+
+def refresh(root, nodes, edges):
+ """Exercise the same scratch overlay/join owner used by the extraction facade."""
+ fresh = {"nodes": [], "edges": []}
+ added_nodes, added_edges = [], []
+ failures = resolve_qt_qml([root / "backend.cpp"], [fresh], added_nodes, added_edges,
+ root=root, context_nodes=nodes, context_edges=edges)
+ assert not failures and not fresh.get("qml_failures") and not fresh.get("qt_failures")
+ return fresh, {"nodes": nodes + added_nodes, "edges": edges + added_edges}
+
+
+@pytest.mark.parametrize("qualified", [True, False])
+def test_req_qml008_ac02_borrowed_complete_header_is_not_counted_as_two_definitions(tmp_path, qualified):
+ """Two representations of one source body keep the canonical method and real containment."""
+ complete, nodes, edges = accepted_context(tmp_path, qualified=qualified)
+ method = canonical_run(complete)
+ before = copy.deepcopy((nodes, edges))
+ fresh = {"nodes": [], "edges": []}
+ enrich_qt_cpp([tmp_path / "backend.cpp"], [fresh], root=tmp_path,
+ accepted_nodes=nodes, accepted_edges=edges)
+ assert not fresh.get("qml_failures")
+ member = sites(fresh, "member")[0]
+ metadata = qt_metadata(member)
+ assert metadata["class_name"] == "Shared::Backend" and metadata["class_id"]
+ assert metadata["generic_target_id"] == method["id"]
+ assert metadata["owner_id"] == metadata["class_id"]
+ assert any(edge["source"] == metadata["class_id"] and edge["target"] == member["id"]
+ and edge["relation"] == "contains" and edge["confidence"] == "EXTRACTED"
+ for edge in fresh["edges"])
+ assert (nodes, edges) == before
+
+
+@pytest.mark.parametrize("qualified", [True, False])
+def test_req_qml016_ac01_context_pipeline_preserves_owned_emission_through_json_reload(tmp_path, qualified):
+ """A refreshed implementation joins unchanged signal facts without rewriting borrowed data."""
+ complete, nodes, edges = accepted_context(tmp_path, qualified=qualified)
+ method = canonical_run(complete)
+ before = copy.deepcopy((nodes, edges))
+ fresh, assembled = refresh(tmp_path, nodes, edges)
+ member = sites(fresh, "member")[0]
+ member_metadata = qt_metadata(member)
+ assert member_metadata["class_id"] and member_metadata["generic_target_id"] == method["id"]
+ emission = sites(fresh, "emission")[0]
+ metadata = qt_metadata(emission)
+ assert metadata["owner_id"] == method["id"] and metadata["owner_class"] == "Shared::Backend"
+ assert metadata["status"] == "resolved" and metadata["target_id"]
+ span = metadata["span"]
+ original = (tmp_path / "backend.cpp").read_bytes()
+ assert original[span["start_byte"]:span["end_byte"]] == b"changed()"
+ graph = build_from_json(assembled, root=tmp_path)
+ assert graph.has_edge(member_metadata["class_id"], member["id"])
+ assert graph.has_edge(method["id"], emission["id"])
+ assert graph.has_edge(emission["id"], metadata["target_id"])
+ assert not any(edge.get("relation") == "calls" for _, _, edge in graph.edges(emission["id"], data=True))
+ path = tmp_path / "refreshed.json"
+ assert to_json(graph, {}, str(path))
+ reloaded = build_from_json(json.loads(path.read_text(encoding="utf-8")), root=tmp_path)
+ assert reloaded.has_edge(method["id"], emission["id"])
+ assert reloaded.has_edge(emission["id"], metadata["target_id"])
+ assert reloaded.has_edge(member_metadata["class_id"], member["id"])
+ assert qt_metadata(reloaded.nodes[emission["id"]])["owner_id"] == method["id"]
+ assert (nodes, edges) == before
+
+
+def test_req_qml008_ac02_borrowed_distinct_complete_bodies_remain_ambiguous(tmp_path):
+ """Deduplication may join representations of one body, never two original source bodies."""
+ _, nodes, edges = accepted_context(tmp_path, qualified=False, conflict=True)
+ classes = sites({"nodes": nodes}, "class")
+ assert len(classes) == 2 and all(qt_metadata(node)["is_definition"] is True for node in classes)
+ before = copy.deepcopy((nodes, edges))
+ fresh, _ = refresh(tmp_path, nodes, edges)
+ member = sites(fresh, "member")[0]
+ emission = sites(fresh, "emission")[0]
+ assert qt_metadata(member)["class_id"] == ""
+ assert qt_metadata(emission)["status"] != "resolved" and qt_metadata(emission)["target_id"] == ""
+ assert (nodes, edges) == before
+
+
+@pytest.mark.parametrize("body", [False, None])
+def test_req_qml008_ac02_borrowed_missing_body_authority_stays_unavailable(tmp_path, body):
+ """A forward/legacy metadata record cannot substitute for accepted complete-body authority."""
+ _, nodes, edges = accepted_context(tmp_path, qualified=False)
+ for node in nodes:
+ if qt_metadata(node).get("kind") == "class":
+ update_qt(node, is_definition=body)
+ before = copy.deepcopy((nodes, edges))
+ fresh, _ = refresh(tmp_path, nodes, edges)
+ assert qt_metadata(sites(fresh, "member")[0])["class_id"] == ""
+ emission = qt_metadata(sites(fresh, "emission")[0])
+ assert emission["status"] == "unavailable" and emission["target_id"] == ""
+ assert not any(edge.get("context") == "qt_signal_emit" for edge in fresh["edges"])
+ assert (nodes, edges) == before
+
+
+@pytest.mark.parametrize("spelling", ["dot", "windows_separator", "absolute"])
+def test_req_qml008_ac02_borrowed_body_provenance_keeps_equivalent_path_representation(tmp_path, spelling):
+ """A valid alternate path representation cannot manufacture a second copy of one body."""
+ complete, nodes, edges = accepted_context(tmp_path, qualified=False)
+ method = canonical_run(complete)
+ for node in nodes:
+ if qt_metadata(node).get("kind") == "class":
+ node["source_file"] = str(tmp_path / "backend.hpp") if spelling == "absolute" else (
+ "./backend.hpp" if spelling == "dot" else ".\\backend.hpp")
+ before = copy.deepcopy((nodes, edges))
+ fresh = {"nodes": [], "edges": []}
+ enrich_qt_cpp([tmp_path / "backend.cpp"], [fresh], root=tmp_path,
+ accepted_nodes=nodes, accepted_edges=edges)
+ member = qt_metadata(sites(fresh, "member")[0])
+ assert member["class_id"] and member["generic_target_id"] == method["id"]
+ assert member["class_name"] == "Shared::Backend" and (nodes, edges) == before
diff --git a/tests/test_qt_cpp_definition_ownership.py b/tests/test_qt_cpp_definition_ownership.py
new file mode 100644
index 0000000000..243d8c8574
--- /dev/null
+++ b/tests/test_qt_cpp_definition_ownership.py
@@ -0,0 +1,266 @@
+"""REQ-QML-008-AC02/016/017 retain canonical implementation ownership."""
+import json
+import re
+import copy
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.cluster import cluster
+from graphify.export import to_html, to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_mapping import map_cpp
+from graphify.extractors.qt_cpp_syntax import read_cpp
+from tests.qt_analysis_helpers import analysis, sites
+
+
+HEADER = '''namespace Shared {
+class Backend : public QObject {
+ Q_OBJECT
+signals:
+ void changed();
+public:
+ void run();
+};
+}
+'''
+CPP = '''#include "backend.hpp"
+using namespace Shared;
+void Backend::run() {
+ emit changed();
+}
+'''
+FORWARD = 'namespace Shared { class Backend; }\nQQmlApplicationEngine *admission_only;\n'
+
+
+def canonical_run(result):
+ """The generic facade already records the header/implementation identity."""
+ selected = [node for node in result["nodes"] if node.get("_callable")
+ and node.get("source_file") == "backend.hpp" and node.get("label") == ".run()"]
+ assert len(selected) == 1
+ return selected[0]
+
+
+def payload(path, key):
+ match = re.search(r"const " + key + r" = (\[.*?\]);", path.read_text(encoding="utf-8"), re.S)
+ assert match
+ return json.loads(match[1])
+
+
+@pytest.mark.parametrize("forward", [False, True])
+def test_req_qml008_ac02_definition_provenance_keeps_emission_owned_through_aggregate(tmp_path, forward):
+ """Using-namespace syntax must reuse the facade's exact accepted definition owner."""
+ sources = {"backend.hpp": HEADER, "backend.cpp": CPP}
+ if forward:
+ sources["forward.hpp"] = FORWARD
+ result = analysis(tmp_path, sources)
+ method = canonical_run(result)
+ assert method["definition_file"] == "backend.cpp" and method["definition_location"] == "L3"
+ member = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp")
+ emission = sites(result, "emission")[0]
+ signal = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "changed")
+ assert qt_metadata(member)["generic_target_id"] == method["id"]
+ assert qt_metadata(member)["class_name"] == "Shared::Backend"
+ assert qt_metadata(member)["class_id"]
+ assert qt_metadata(emission)["owner_id"] == method["id"]
+ assert qt_metadata(emission)["status"] == "resolved"
+ assert qt_metadata(emission)["target_id"] == signal["id"]
+ source = CPP.encode()
+ span = qt_metadata(emission)["span"]
+ assert source[span["start_byte"]:span["end_byte"]] == b"changed()"
+ graph = build_from_json(result, root=tmp_path)
+ assert graph.has_edge(method["id"], emission["id"])
+ assert graph.has_edge(emission["id"], signal["id"])
+ assert not any(edge.get("relation") == "calls" for _, _, edge in graph.edges(emission["id"], data=True))
+ groups = cluster(graph)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, groups, str(path))
+ reloaded = build_from_json(json.loads(path.read_text(encoding="utf-8")), root=tmp_path)
+ assert reloaded.has_edge(method["id"], emission["id"]) and reloaded.has_edge(emission["id"], signal["id"])
+ html = tmp_path / "graph.html"
+ assert to_html(reloaded, groups, str(html), node_limit=2)
+ nodes, edges = payload(html, "RAW_NODES"), payload(html, "RAW_EDGES")
+ assert not [node for node in nodes if node["label"] in {"Qt member: run", "Qt emission: changed"}
+ and not any(node["id"] in (edge["from"], edge["to"]) for edge in edges)]
+
+
+def test_req_qml016_ac01_forward_declaration_does_not_compete_with_complete_definition(tmp_path):
+ """A fully qualified control still rejects a forward declaration as a second body."""
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP.replace("Backend::run", "Shared::Backend::run"),
+ "forward.hpp": FORWARD})
+ method = canonical_run(result)
+ emission = sites(result, "emission")[0]
+ assert qt_metadata(emission)["owner_id"] == method["id"]
+ assert qt_metadata(emission)["status"] == "resolved"
+ classes = sites(result, "class")
+ assert len(classes) == 2
+ assert {node["source_file"]: qt_metadata(node)["is_definition"] for node in classes} == {
+ "backend.hpp": True, "forward.hpp": False}
+
+
+def test_req_qml017_ac02_namespace_definition_owns_source_backed_qml_access(tmp_path):
+ """Loader and property sites keep the same proven canonical implementation owner."""
+ cpp = CPP.replace(" emit changed();", ''' QQmlApplicationEngine engine;
+ engine.load("qrc:/App/Main.qml");
+ QObject *root = engine.rootObjects().first();
+ root->property("value");
+ emit changed();''')
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": cpp,
+ "Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "app.qrc": 'Main.qml'})
+ method = canonical_run(result)
+ owned = sites(result, "qml_load") + sites(result, "qml_root") + sites(result, "qml_access")
+ assert len(owned) == 3
+ assert all(qt_metadata(node)["owner_id"] == method["id"] for node in owned)
+ assert all(qt_metadata(node)["status"] == "resolved" for node in owned)
+ graph = build_from_json(result, root=tmp_path)
+ assert all(graph.has_edge(method["id"], node["id"]) for node in owned)
+ # The owning method must survive the same publication/reload boundary as
+ # native event ownership, including reverse QML property access sites.
+ path = tmp_path / "graph.json"
+ assert to_json(graph, cluster(graph), str(path))
+ reloaded = build_from_json(json.loads(path.read_text(encoding="utf-8")), root=tmp_path)
+ assert all(reloaded.has_edge(method["id"], node["id"]) for node in owned)
+ assert all(qt_metadata(reloaded.nodes[node["id"]])["owner_id"] == method["id"] for node in owned)
+
+
+def mapped_run(root, nodes, edges):
+ """Reparse original source against the accepted boundary, including corrupt-proof controls."""
+ mapping = map_cpp(read_cpp(root / "backend.cpp", root), nodes, edges, root=root)
+ mapping.bind_classes([])
+ return next(record for record in mapping.functions if record["name"] == "run")
+
+
+@pytest.mark.parametrize("spelling", ["absolute", "dot", "windows_separator"])
+def test_req_qml008_ac02_definition_paths_are_normalized_without_changing_source_spans(tmp_path, spelling):
+ """Equivalent accepted path representations still select the exact definition."""
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP})
+ nodes = copy.deepcopy(result["nodes"])
+ method = canonical_run({"nodes": nodes})
+ method["definition_file"] = str(tmp_path / "backend.cpp") if spelling == "absolute" else (
+ "./backend.cpp" if spelling == "dot" else ".\\backend.cpp")
+ before = copy.deepcopy((nodes, result["edges"]))
+ record = mapped_run(tmp_path, nodes, result["edges"])
+ assert record["node_id"] == method["id"] and record["class_name"] == "Shared::Backend"
+ assert CPP.encode()[record["span"]["start_byte"]:record["span"]["end_byte"]] == b"void Backend::run() {\n emit changed();\n}"
+ assert (nodes, result["edges"]) == before
+
+
+@pytest.mark.parametrize("corruption", ["wrong_file", "wrong_line", "not_callable", "inferred_owner"])
+def test_req_qml008_ac02_definition_recovery_rejects_incomplete_or_foreign_proof(tmp_path, corruption):
+ """No name-only replacement can compensate for rejected definition/ownership evidence."""
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP})
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ method = canonical_run({"nodes": nodes})
+ if corruption == "wrong_file":
+ method["definition_file"] = "other.cpp"
+ elif corruption == "wrong_line":
+ method["definition_location"] = "L2"
+ elif corruption == "not_callable":
+ method["_callable"] = False
+ else:
+ for edge in edges:
+ if edge["target"] == method["id"] and edge["relation"] == "method":
+ edge["confidence"] = "INFERRED"
+ before = copy.deepcopy((nodes, edges))
+ record = mapped_run(tmp_path, nodes, edges)
+ assert record["class_id"] == "" and record["class_name"] == "Backend"
+ assert (nodes, edges) == before
+
+
+def test_req_qml008_ac02_conflicting_callable_identity_is_not_arbitrarily_selected(tmp_path):
+ """Two accepted callables claiming the same definition remain explicitly ambiguous."""
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP})
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ method = canonical_run({"nodes": nodes})
+ conflicting = {**method, "id": "conflicting_canonical_callable"}
+ nodes.append(conflicting)
+ edges.extend({**edge, "target": conflicting["id"]} for edge in list(edges)
+ if edge["target"] == method["id"] and edge["relation"] == "method")
+ record = mapped_run(tmp_path, nodes, edges)
+ assert record["node_id"] == "" and record["status"] == "ambiguous"
+ assert set(record["candidates"]) == {method["id"], conflicting["id"]}
+
+
+def test_req_qml008_ac02_multiple_real_class_bodies_are_not_forward_declarations(tmp_path):
+ """Genuine competing complete definitions cannot acquire an arbitrary native target."""
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "duplicate.hpp": HEADER, "backend.cpp": CPP})
+ classes = sites(result, "class")
+ assert len(classes) == 2 and all(qt_metadata(node)["is_definition"] is True for node in classes)
+ implementation = next(node for node in sites(result, "member") if node["source_file"] == "backend.cpp")
+ assert qt_metadata(implementation)["class_id"] == ""
+ emission = sites(result, "emission")[0]
+ assert qt_metadata(emission)["status"] != "resolved" and qt_metadata(emission)["target_id"] == ""
+
+
+@pytest.mark.parametrize("body", [False, None])
+def test_req_qml008_ac02_missing_body_proof_cannot_become_a_native_provider(tmp_path, body):
+ """Literal registration tolerates a forward declaration, but not absent definition proof."""
+ from graphify.extractors.qt_cpp_facts import update_qt
+ from graphify.qt_event_index import QtEventIndex
+ from graphify.qt_qml_bridge import build_qt_qml_bridge
+
+ registration = 'void install(){qmlRegisterType("Demo",1,0,"Backend");}'
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": CPP,
+ "forward.hpp": FORWARD, "register.cpp": registration})
+ providers = sites(result, "registration")
+ assert len(providers) == 1 and qt_metadata(providers[0])["status"] == "resolved"
+ baseline = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path)
+ assert baseline.module_type("Demo", 1, 0, "Backend").status == "resolved"
+ nodes = copy.deepcopy(result["nodes"])
+ for node in nodes:
+ if qt_metadata(node).get("kind") == "class" and node["source_file"] == "backend.hpp":
+ update_qt(node, is_definition=body)
+ assert QtEventIndex(nodes).class_name("Shared::Backend") == ""
+ unsupported = build_qt_qml_bridge(nodes, result["edges"], root=tmp_path)
+ assert unsupported.module_type("Demo", 1, 0, "Backend").status == "unavailable"
+ assert unsupported.unresolved[providers[0]["id"]]["reason"] == "native_class_definition_unavailable"
+
+
+def test_req_qml008_ac02_external_and_function_local_classes_keep_unknown_ownership(tmp_path):
+ """Proven source containment does not invent an unavailable native class owner."""
+ source = '#include "external.hpp"\nvoid External::run(){QObject *object;}\n' + (
+ 'void utility(){struct Local {void work(){}}; QObject *object;}')
+ result = analysis(tmp_path, {"unresolved.cpp": source})
+ external = next(node for node in sites(result, "member") if qt_metadata(node)["class_name"] == "External")
+ local = next(node for node in sites(result, "class") if qt_metadata(node)["raw_name"] == "Local")
+ assert qt_metadata(external)["class_id"] == "" and qt_metadata(external)["generic_target_id"]
+ assert qt_metadata(local)["class_id"] == "" and qt_metadata(local)["is_definition"] is True
+ function = next(node for node in result["nodes"] if node.get("label") == "utility()")
+ for node, owner in ((external, qt_metadata(external)["generic_target_id"]), (local, function["id"])):
+ assert qt_metadata(node)["owner_id"] == owner
+ assert any(edge["source"] == owner and edge["target"] == node["id"]
+ and edge["relation"] == "contains" and edge["confidence"] == "EXTRACTED"
+ and edge["context"] == "qt_source_site" for edge in result["edges"])
+ assert qt_metadata(local)["generic_target_id"] == "" and qt_metadata(local)["status"] == "unavailable"
+
+
+def test_req_qml008_ac02_unrelated_plain_cpp_keeps_generic_identities(tmp_path):
+ """The Qt overlay correction does not change admission or ordinary C++ calls."""
+ source = "int helper(){return 1;}\nint use(){return helper();}"
+ result = analysis(tmp_path, {"plain.cpp": source})
+ assert not any(qt_metadata(node) for node in result["nodes"])
+ functions = {node["label"]: node["id"] for node in result["nodes"] if node.get("_callable")}
+ assert set(functions) == {"helper()", "use()"}
+ assert any(edge["source"] == functions["use()"] and edge["target"] == functions["helper()"]
+ and edge["relation"] == "calls" for edge in result["edges"])
+
+
+def test_req_qml008_ac02_definition_and_emission_spans_use_original_bom_crlf_unicode_bytes(tmp_path):
+ """Recovery changes ownership only; original encoded source still owns every span."""
+ cpp = ("\ufeff// caf\u00e9 \u96ea\n" + CPP).replace("\n", "\r\n")
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": cpp, "forward.hpp": FORWARD})
+ method = canonical_run(result)
+ assert method["definition_file"] == "backend.cpp" and method["definition_location"] == "L4"
+ record = mapped_run(tmp_path, result["nodes"], result["edges"])
+ assert record["node_id"] == method["id"] and record["class_name"] == "Shared::Backend"
+ original = (tmp_path / "backend.cpp").read_bytes()
+ assert original == cpp.encode("utf-8")
+ definition = record["span"]
+ assert original[definition["start_byte"]:definition["end_byte"]] == (
+ b"void Backend::run() {\r\n emit changed();\r\n}")
+ emission = sites(result, "emission")[0]
+ span = qt_metadata(emission)["span"]
+ assert original[span["start_byte"]:span["end_byte"]] == b"changed()"
+ assert qt_metadata(emission)["owner_id"] == method["id"]
+ assert qt_metadata(emission)["status"] == "resolved"
diff --git a/tests/test_qt_cpp_exposure.py b/tests/test_qt_cpp_exposure.py
new file mode 100644
index 0000000000..a641f8122c
--- /dev/null
+++ b/tests/test_qt_cpp_exposure.py
@@ -0,0 +1,139 @@
+"""Qt source declarations, canonical merges and source-only registration guards."""
+from __future__ import annotations
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_cpp_test_helpers import HEADER, collect, facts
+
+
+def test_members_properties_and_notify_have_exact_original_evidence(tmp_path):
+ result = collect(tmp_path, {"backend.hpp": HEADER})
+ class_node = facts(result, "class", "Backend")[0]
+ class_md = qt_metadata(class_node)
+ assert class_md["source_q_object"] and class_md["is_qobject"]
+ assert class_md["generic_target_id"] in {node["id"] for node in result["nodes"]}
+ members = {qt_metadata(node)["raw_name"]: qt_metadata(node) for node in facts(result, "member")}
+ assert {name: md["roles"] for name, md in members.items()} == {
+ "value": [], "next": ["invokable"], "ordinary": [], "valueChanged": ["signal"],
+ "setValue": ["slot"], "hidden": ["invokable"]}
+ assert members["valueChanged"]["signature"] == "valueChanged(int)"
+ assert members["valueChanged"]["parameter_names"] == ["value"]
+ assert members["hidden"]["access"] == "private"
+ prop = qt_metadata(facts(result, "property", "value")[0])
+ assert {key: prop[key] for key in ("raw_type", "read", "write", "notify")} == {
+ "raw_type": "int", "read": "value", "write": "setValue", "notify": "valueChanged"}
+ source = HEADER.encode()
+ assert source[prop["span"]["start_byte"]:prop["span"]["end_byte"]] == b"Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)"
+ links = [qt_metadata(node) for node in facts(result, "property_accessor")]
+ assert {link["accessor_role"]: link["status"] for link in links} == {"read": "resolved", "write": "resolved", "notify": "resolved"}
+ assert all(link["generic_target_id"] in {node["id"] for node in result["nodes"]} for link in links)
+
+
+def test_header_implementation_members_reuse_accepted_canonical_ids(tmp_path):
+ source = '#include "backend.hpp"\nint Backend::value() const { return 1; }\nint Backend::next(int amount) { return amount; }'
+ result = collect(tmp_path, {"backend.hpp": HEADER, "backend.cpp": source})
+ for name in ("value", "next"):
+ declarations = [qt_metadata(node) for node in facts(result, "member", name)]
+ assert len(declarations) == 2
+ assert len({item["generic_target_id"] for item in declarations}) == 1
+ assert all(item["generic_target_id"] and item["class_id"] for item in declarations)
+ assert not any(result.get("qml_failures") for result in result["per_file"])
+
+
+def test_notify_same_name_ordinary_function_is_not_signal(tmp_path):
+ source = '''class B: public QObject { Q_OBJECT Q_PROPERTY(int value READ value NOTIFY changed)
+public: int value(); void changed(int value); };'''
+ result = collect(tmp_path, {"b.hpp": source})
+ notify = next(qt_metadata(node) for node in facts(result, "property_accessor") if qt_metadata(node)["accessor_role"] == "notify")
+ assert notify["status"] == "unavailable" and notify["generic_target_id"] == ""
+ assert not any(edge.get("metadata", {}).get("qt", {}).get("accessor_role") == "notify" for edge in result["edges"])
+
+
+def test_identifier_named_element_flags_and_quoted_rejection(tmp_path):
+ source = '''class State: public QObject { Q_OBJECT QML_NAMED_ELEMENT(State) QML_SINGLETON QML_UNCREATABLE("Use singleton") };
+class Bad: public QObject { Q_OBJECT QML_NAMED_ELEMENT("Bad") };
+class Hidden: public QObject { Q_OBJECT QML_ANONYMOUS };'''
+ result = collect(tmp_path, {"types.hpp": source})
+ registrations = [qt_metadata(node) for node in facts(result, "registration")]
+ state = next(md for md in registrations if md["class_name"] == "State")
+ assert state["raw_name"] == "State" and state["singleton"] and not state["creatable"]
+ bad = next(md for md in registrations if md["class_name"] == "Bad")
+ assert bad["status"] == "unsupported" and bad["reason"] == "named_element_requires_identifier"
+ hidden = next(md for md in registrations if md["class_name"] == "Hidden")
+ assert hidden["anonymous"] and not hidden["raw_name"] and not hidden["creatable"]
+
+
+def test_literal_registration_variants_exact_versions_and_class_mapping(tmp_path):
+ calls = '''void install(Backend *instance) {
+qmlRegisterType("Demo.Types",1,2,"Backend");
+qmlRegisterUncreatableType("Demo.Types",1,2,"ReadOnly","reason");
+qmlRegisterSingletonType("Demo.State",2,0,"State",factory);
+qmlRegisterSingletonInstance("Demo.State",2,0,"Instance",instance);
+qmlRegisterAnonymousType("Demo.Types",1);
+}'''
+ result = collect(tmp_path, {"backend.hpp": HEADER, "register.cpp": calls})
+ literal = [qt_metadata(node) for node in facts(result, "registration") if not qt_metadata(node)["module_required"]]
+ assert len(literal) == 5 and all(md["status"] == "resolved" for md in literal)
+ assert len({md["class_id"] for md in literal}) == 1
+ assert {(md["uri"], md["major"], md["minor"], md["raw_name"]) for md in literal} == {
+ ("Demo.Types", 1, 2, "Backend"), ("Demo.Types", 1, 2, "ReadOnly"),
+ ("Demo.State", 2, 0, "State"), ("Demo.State", 2, 0, "Instance"), ("Demo.Types", 1, 0, "")}
+
+
+def test_dynamic_unsupported_and_missing_arguments_do_not_become_providers(tmp_path):
+ calls = '''void install() {
+qmlRegisterType(uri,1,0,"Dynamic");
+if (enabled) { qmlRegisterType("Demo",1,0,"Conditional"); }
+qmlRegisterSingletonType("Demo",1,0,"MissingCallback");
+qmlRegisterUnknown("Demo",1,0,"Unknown");
+qmlRegisterType("Demo",1,0,"Missing");
+}'''
+ result = collect(tmp_path, {"backend.hpp": HEADER, "register.cpp": calls})
+ literal = [qt_metadata(node) for node in facts(result, "registration") if not qt_metadata(node)["module_required"]]
+ assert len(literal) == 5 and all(md["status"] != "resolved" for md in literal)
+ assert any(md["reason"] == "conditional_registration" for md in literal)
+ assert any(md["reason"] == "registration_mechanism_unsupported" for md in literal)
+ assert any(md["reason"] == "class_not_in_corpus" for md in literal)
+
+
+def test_foreign_wrapper_is_explicit_and_nested_macros_do_not_expose_outer(tmp_path):
+ source = '''class Outer: public QObject { Q_OBJECT public:
+class Inner: public QObject { Q_OBJECT QML_ELEMENT }; };
+class Wrapper: public QObject { Q_OBJECT QML_NAMED_ELEMENT(Foreign) QML_FOREIGN(Outer) };'''
+ result = collect(tmp_path, {"nested.hpp": source})
+ declarations = [qt_metadata(node) for node in facts(result, "registration")]
+ assert {md["class_name"] for md in declarations} == {"Outer::Inner", "Wrapper"}
+ wrapper = next(md for md in declarations if md["class_name"] == "Wrapper")
+ assert wrapper["status"] == "unsupported" and wrapper["unsupported_macros"] == ["QML_FOREIGN"]
+
+
+def test_conditional_macro_and_property_remain_uncertain(tmp_path):
+ source = '''class Backend: public QObject {
+Q_OBJECT
+#if ENABLE_EXPOSURE
+QML_ELEMENT
+Q_PROPERTY(int value READ value)
+#endif
+public: int value();
+};'''
+ result = collect(tmp_path, {"backend.hpp": source})
+ registration = qt_metadata(facts(result, "registration")[0])
+ assert registration["status"] == "dynamic" and registration["reason"] == "conditional_exposure"
+ prop = qt_metadata(facts(result, "property")[0])
+ assert prop["status"] == "dynamic" and prop["reason"] == "conditional_property_declaration"
+
+
+def test_additional_unsupported_macro_has_original_annotation_evidence(tmp_path):
+ result = collect(tmp_path, {"backend.hpp": HEADER.replace("QML_NAMED_ELEMENT(Backend)", "QML_NAMED_ELEMENT(Backend) QML_EXTRA_VERSION(2, 0)")})
+ md = qt_metadata(facts(result, "registration")[0])
+ assert md["status"] == "unsupported" and md["unsupported_macros"] == ["QML_EXTRA_VERSION"]
+ macro = next(item for item in md["annotations"] if item["name"] == "QML_EXTRA_VERSION")
+ assert macro["args"] == ["2", "0"]
+ source = (tmp_path / "backend.hpp").read_bytes()
+ assert source[macro["span"]["start_byte"]:macro["span"]["end_byte"]] == b"QML_EXTRA_VERSION(2, 0)"
+
+
+def test_pointer_property_name_and_type_remain_separate(tmp_path):
+ source = 'class B: public QObject { Q_OBJECT Q_PROPERTY(QObject *child READ child) public: QObject *child(); };'
+ result = collect(tmp_path, {"b.hpp": source})
+ prop = qt_metadata(facts(result, "property", "child")[0])
+ assert prop["raw_type"] == "QObject*" and prop["status"] == "resolved" and prop["read"] == "child"
diff --git a/tests/test_qt_cpp_file_role_updates.py b/tests/test_qt_cpp_file_role_updates.py
new file mode 100644
index 0000000000..296b54350b
--- /dev/null
+++ b/tests/test_qt_cpp_file_role_updates.py
@@ -0,0 +1,258 @@
+"""INC-QML-33/35 retain file roles and retire only stale generated import endpoints."""
+from __future__ import annotations
+
+import json
+import os
+import stat
+
+import pytest
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_cpp_file_roles import cpp_fixture
+from tests.test_qt_final_incremental_parity import clean, normalized, run
+
+
+def cpp_membership(graph):
+ """The C++ endpoint is its original generic file ID, never a guessed class."""
+ return next(qml_metadata(node) for _, node in graph.nodes(data=True)
+ if qml_metadata(node).get("kind") == "membership_resolution"
+ and qml_metadata(node).get("source_kind") == "cpp")
+
+
+def assert_file_context(graph):
+ target = cpp_membership(graph)["target_id"]
+ assert target and graph.nodes[target]["source_file"] == "backend.cpp"
+ unknown = [identity for identity, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == "emission" and not qt_metadata(node).get("owner_id")]
+ assert unknown and all(graph.has_edge(target, identity) for identity in unknown)
+ assert all(any(endpoint == identity and data.get("context") == "qt_source_file"
+ and data["relation"] == "contains" and data["confidence"] == "EXTRACTED"
+ for _, endpoint, data in graph.edges(target, data=True)) for identity in unknown)
+ return target
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml020_ac03_cpp_transport_edits_and_removal_preserve_exact_membership(tmp_path, monkeypatch, operation):
+ """Cold/warm/manual/watch/JSON products retain file roles as native type proof changes."""
+ cpp_fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold-cache")
+ assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ file_id = assert_file_context(initial)
+ path = tmp_path / "backend.cpp"
+ original = path.read_bytes()
+
+ # Namespace-import type authority is conservatively incomplete, but the
+ # accepted file still owns source context and explicit build membership.
+ path.write_bytes(original.replace(b"using QObject = int;", b"using namespace Hidden;"))
+ changed = run(tmp_path, monkeypatch, operation, [path])
+ assert assert_file_context(changed) == file_id
+ assert changed.nodes[file_id]["metadata"]["cpp_constructor_types"]["complete"] is False
+ assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".changed-cache"))
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(changed)
+
+ # An unavailable source cannot keep its unchanged module declaration's
+ # previous endpoint; restoration produces the same accepted file identity.
+ path.unlink()
+ removed = run(tmp_path, monkeypatch, operation, [path])
+ assert file_id not in removed
+ assert "missing" not in removed, "Deleted C++ source left its generated include endpoint"
+ assert cpp_membership(removed)["status"] == "unavailable"
+ assert not cpp_membership(removed)["target_id"]
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ path.write_bytes(original)
+ restored = run(tmp_path, monkeypatch, operation, [path])
+ assert assert_file_context(restored) == file_id
+ assert "missing" in restored
+ assert normalized(restored) == normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(restored)
+
+
+def products(root):
+ return {name: (root / "graphify-out" / name).read_bytes()
+ for name in ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")}
+
+
+def has_import(graph, source):
+ return any(data.get("relation") == "imports" and data.get("source_file") == source
+ and data.get("_origin") == "ast"
+ for _, _, data in graph.edges("missing", data=True))
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac04_cpp_deleted_import_keeps_still_referenced_external_endpoint(tmp_path, monkeypatch, operation):
+ """A second real import protects the shared endpoint until its final source reference disappears."""
+ cpp_fixture(tmp_path)
+ keeper = tmp_path / "keeper.cpp"
+ original = b'#include "missing.hpp"\nint keep() { return 1; }\n'
+ keeper.write_bytes(original)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert has_import(initial, "backend.cpp") and has_import(initial, "keeper.cpp")
+ assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".initial-cache"))
+ source = tmp_path / "backend.cpp"
+ backend = source.read_bytes()
+ source.unlink()
+ removed = run(tmp_path, monkeypatch, operation, [source])
+ assert "missing" in removed and has_import(removed, "keeper.cpp")
+ assert not has_import(removed, "backend.cpp")
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+
+ # Removing the final import is a real C++ edit, independent of metadata
+ # membership and the earlier source deletion. Re-addition restores its ID.
+ keeper.write_bytes(original.split(b"\n", 1)[1])
+ edited = run(tmp_path, monkeypatch, operation, [keeper])
+ assert "missing" not in edited
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited-cache"))
+ keeper.write_bytes(original)
+ restored = run(tmp_path, monkeypatch, operation, [keeper])
+ assert "missing" in restored and has_import(restored, "keeper.cpp")
+ assert normalized(restored) == normalized(removed)
+ source.write_bytes(backend)
+ restored = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(restored) == normalized(initial)
+ before = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(restored)
+ assert products(tmp_path) == before
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary")
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac06_cpp_orphan_cleanup_failed_publication_preserves_products_and_retries(tmp_path, monkeypatch, operation):
+ """Real graph-write rejection preserves old products/cache; deletion retry retires the orphan."""
+ from graphify.watch import _rebuild_code
+ from graphify.cache import cache_dir
+ from tests.test_qt_final_incremental_parity import published
+ from tests.test_qt_readonly_publication import durable
+
+ cpp_fixture(tmp_path)
+ # Qt cohort sources deliberately bypass individual AST reuse. An unrelated
+ # real Python source supplies old cache bytes that rejected publication must
+ # preserve, rather than fabricating an entry or claiming an empty cache.
+ (tmp_path / "keep.py").write_bytes(b"def untouched(): return 7\n")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert "missing" in initial
+ accepted = durable(tmp_path)
+ cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")}
+ assert cache
+ source = tmp_path / "backend.cpp"
+ original = source.read_bytes()
+ source.unlink()
+ target = tmp_path / "graphify-out/graph.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, monkeypatch, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert durable(tmp_path) == accepted
+ assert all(path.exists() and path.read_bytes() == data for path, data in cache.items())
+ assert normalized(published(tmp_path)) == normalized(initial)
+ assert not target.stat().st_mode & stat.S_IWRITE
+ assert not list(target.parent.glob(".gfy-*.tmp"))
+ finally:
+ target.chmod(stat.S_IWRITE)
+ repaired = run(tmp_path, monkeypatch, operation, [source])
+ assert "missing" not in repaired
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache"))
+ accepted = durable(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired)
+ assert durable(tmp_path) == accepted
+ source.write_bytes(original)
+ assert normalized(run(tmp_path, monkeypatch, operation, [source])) == normalized(initial)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac04_cpp_import_cleanup_preserves_same_label_source_and_semantic_roles(tmp_path, monkeypatch, operation):
+ """Exact endpoint identity controls cleanup; equal display labels do not erase unrelated facts."""
+ cpp_fixture(tmp_path)
+ (tmp_path / "keeper.cpp").write_bytes(b"class missing {};\n")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ source_owned = next(identity for identity, node in initial.nodes(data=True)
+ if node.get("label") == "missing" and node.get("source_file") == "keeper.cpp")
+ output = tmp_path / "graphify-out/graph.json"
+ raw = json.loads(output.read_text(encoding="utf-8"))
+ retained = [{"id": "authored_missing", "label": "missing", "type": "external", "external": True,
+ "file_type": "concept", "source_file": "", "_origin": "semantic"},
+ {"id": "manual_missing", "label": "missing", "type": "concept",
+ "file_type": "concept", "source_file": ""}]
+ raw["nodes"].extend(retained)
+ output.write_text(json.dumps(raw), encoding="utf-8")
+ source = tmp_path / "backend.cpp"
+ source.unlink()
+ removed = run(tmp_path, monkeypatch, operation, [source])
+ assert "missing" not in removed
+ assert {"authored_missing", "manual_missing", source_owned} <= set(removed)
+ assert removed.nodes["authored_missing"]["_origin"] == "semantic"
+ expected_nodes, expected_edges = normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ actual_nodes, actual_edges = normalized(removed)
+ assert set(actual_nodes) == set(expected_nodes) | {node["id"] for node in retained}
+ assert all(actual_nodes[identity] == data for identity, data in expected_nodes.items())
+ assert actual_edges == expected_edges
+ before = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed)
+ assert products(tmp_path) == before
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("corruption", ["semantic_origin", "unmarked_origin", "foreign_source",
+ "conflicting_source", "context", "confidence", "opposite_direction",
+ "metadata", "metadata_empty", "foreign_origin", "ast_origin",
+ "callable", "type", "source", "label",
+ "partial_direction", "foreign_direction"])
+def test_req_qml011_ac04_cpp_import_cleanup_requires_exact_prior_source_proof(tmp_path, monkeypatch, operation, corruption):
+ """Retired unproved imports cannot authorize cleanup, even when the remaining candidate is valid."""
+ cpp_fixture(tmp_path)
+ (tmp_path / "keeper.cpp").write_bytes(b"int retained() { return 7; }\n")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ run(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out/graph.json"
+ raw = json.loads(output.read_bytes())
+ edge = next(edge for edge in raw["links"] if edge.get("relation") == "imports"
+ and edge.get("source_file") == "backend.cpp")
+ if corruption == "semantic_origin":
+ edge["_origin"] = "semantic"
+ elif corruption == "unmarked_origin":
+ edge.pop("_origin")
+ elif corruption in {"foreign_source", "conflicting_source"}:
+ edge["source_file"] = "foreign.cpp" if corruption == "foreign_source" else "keeper.cpp"
+ elif corruption in {"context", "confidence"}:
+ edge[corruption] = "unproved"
+ elif corruption == "opposite_direction":
+ edge["_src"], edge["_tgt"] = edge["target"], edge["source"]
+ elif corruption in {"metadata", "metadata_empty", "foreign_origin", "ast_origin", "callable", "type", "source", "label"}:
+ target = next(node for node in raw["nodes"] if node["id"] == "missing")
+ key, value = {"metadata": ("metadata", {"note": "authored context"}),
+ "metadata_empty": ("metadata", {}), "foreign_origin": ("_origin", "foreign"),
+ "ast_origin": ("_origin", "ast"), "callable": ("_callable", True),
+ "type": ("type", "class"), "source": ("source_file", "keeper.cpp"),
+ "label": ("label", "authored display")}[corruption]
+ target[key] = value
+ else:
+ edge["_src"] = edge["source"] if corruption == "partial_direction" else "foreign_endpoint"
+ if corruption == "foreign_direction":
+ edge["_tgt"] = edge["target"]
+ output.write_text(json.dumps(raw), encoding="utf-8")
+ source = tmp_path / "backend.cpp"
+ source.unlink()
+ removed = run(tmp_path, monkeypatch, operation, [source])
+ assert "missing" in removed and removed.degree("missing") == 0
+ expected_nodes, expected_edges = normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ actual_nodes, actual_edges = normalized(removed)
+ assert set(actual_nodes) == set(expected_nodes) | {"missing"}
+ assert all(actual_nodes[identity] == data for identity, data in expected_nodes.items())
+ assert actual_edges == expected_edges
+ if corruption == "metadata":
+ assert removed.nodes["missing"]["metadata"] == {"note": "authored context"}
diff --git a/tests/test_qt_cpp_file_roles.py b/tests/test_qt_cpp_file_roles.py
new file mode 100644
index 0000000000..aa52f91431
--- /dev/null
+++ b/tests/test_qt_cpp_file_roles.py
@@ -0,0 +1,200 @@
+"""INC-QML-33 validates generic C++ file provenance at both Qt file joins."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_source_containment import attach_qt_file_sites
+from tests.test_qt_project_membership import apply, facts, fixture
+
+
+def cpp_fixture(root):
+ """Real C++ transport owns includes/shadows; an opaque constructor lacks an owner."""
+ fixture(root)
+ (root / "backend.cpp").write_text(
+ '#include "missing.hpp"\nusing QObject = int;\n'
+ 'class Backend { public: Backend(Unknown count); };\n'
+ 'Backend::Backend(Unknown count) { emit ready(); }\n',
+ encoding="utf-8", newline="")
+ from graphify.extract import extract
+ paths = [root / name for name in ("backend.cpp", "CMakeLists.txt", "resources.qrc", "qml/ResizeHandle.qml")]
+ result = extract(paths, root=root, cache_root=root, parallel=False)
+ assert not result.get("qml_failures")
+ return result
+
+
+def file_node(nodes):
+ return next(node for node in nodes if node.get("label") == "backend.cpp"
+ and node.get("source_file") == "backend.cpp" and not node.get("_callable_class"))
+
+
+def project(root, nodes, edges, declarations, boundary, fresh_ids=()):
+ """Both production joins consume borrowed facts without mutating their dictionaries."""
+ if boundary == "membership":
+ sites = apply(root, nodes, edges, declarations, fresh_ast_ids=fresh_ids)
+ assert len(sites) == 1
+ md = qml_metadata(sites[0])
+ return md["target_id"], md["reason"]
+ unknown = [node for node in nodes if qt_metadata(node).get("kind") == "emission"
+ and not qt_metadata(node).get("owner_id")]
+ assert unknown
+ active = {root / "backend.cpp": {"nodes": unknown, "edges": []}}
+ added = attach_qt_file_sites(active, nodes, [], root=root, fresh_ast_ids=fresh_ids)
+ assert all(edge["context"] == "qt_source_file" and edge["confidence"] == "EXTRACTED" for edge in added)
+ assert all(edge["source"] == edge["_src"] and edge["target"] == edge["_tgt"] for edge in added)
+ assert all(not qt_metadata(node)["owner_id"] for node in unknown)
+ return added[0]["source"] if added else "", ""
+
+
+@pytest.mark.parametrize("boundary", ["membership", "containment"])
+@pytest.mark.parametrize("transport", ["producer", "incomplete", "legacy", "fresh", "reload"])
+def test_req_qml020_ac01_ac03_cpp_file_transport_retains_exact_file_identity(tmp_path, boundary, transport):
+ """Current/old/incomplete/fresh/reloaded file facts retain explicit source links."""
+ result = cpp_fixture(tmp_path)
+ if transport == "reload":
+ output = tmp_path / "graph.json"
+ assert to_json(build_from_json(result, root=tmp_path), {}, str(output))
+ result = json.loads(output.read_text(encoding="utf-8"))
+ nodes = copy.deepcopy(result["nodes"])
+ edges = copy.deepcopy(result.get("edges", result.get("links", [])))
+ target = file_node(nodes)
+ if transport == "incomplete":
+ target["metadata"]["cpp_constructor_types"]["complete"] = False
+ elif transport == "legacy":
+ target.pop("metadata")
+ elif transport == "fresh":
+ target.pop("_origin")
+ declarations = [next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source"
+ and qml_metadata(node).get("source_kind") == "cpp")]
+ borrowed = list(nodes)
+ before = copy.deepcopy(borrowed)
+ target_id, reason = project(tmp_path, nodes, edges, declarations, boundary,
+ {target["id"]} if transport == "fresh" else ())
+ assert target_id == target["id"] and not reason
+ assert borrowed == before
+
+
+CORRUPTIONS = ["foreign", "outside_root", "duplicate", "callable", "class", "function_type",
+ "non_ast", "unmarked", "location", "label", "file_type",
+ "other_metadata", "record_scalar", "version", "bool_version", "complete",
+ "negative_size", "bool_size", "size_type", "includes_type", "includes_limit",
+ "include_encoding", "include_kind", "include_span", "include_position",
+ "include_bounds", "include_foreign", "shadows_type", "shadows_limit",
+ "shadow_encoding", "shadow_scope", "shadow_role", "shadow_position"]
+
+
+def corrupt(nodes, target, corruption):
+ """Change actual producer transport, never a fake decoder or resolver result."""
+ record = target["metadata"]["cpp_constructor_types"]
+ if corruption in {"foreign", "outside_root"}:
+ target["source_file"] = "foreign.cpp" if corruption == "foreign" else "../backend.cpp"
+ elif corruption == "duplicate":
+ nodes.append({**copy.deepcopy(target), "id": target["id"] + "_competing"})
+ elif corruption in {"callable", "class"}:
+ target["_callable" if corruption == "callable" else "_callable_class"] = True
+ elif corruption == "function_type":
+ target["type"] = "function"
+ elif corruption in {"non_ast", "unmarked"}:
+ target["_origin"] = "semantic" if corruption == "non_ast" else None
+ elif corruption in {"location", "label", "file_type"}:
+ target[{"location": "source_location", "label": "label", "file_type": "file_type"}[corruption]] = "wrong"
+ elif corruption == "other_metadata":
+ target["metadata"]["semantic"] = {"kind": "function"}
+ elif corruption == "record_scalar":
+ target["metadata"]["cpp_constructor_types"] = []
+ elif corruption in {"version", "bool_version", "complete", "negative_size", "bool_size", "size_type"}:
+ key, value = {"version": ("contract_version", 2), "bool_version": ("contract_version", True),
+ "complete": ("complete", 1), "negative_size": ("source_size", -1),
+ "bool_size": ("source_size", True), "size_type": ("source_size", "100")}[corruption]
+ record[key] = value
+ elif corruption.endswith("_type"):
+ record[corruption.removesuffix("_type")] = {}
+ elif corruption.endswith("_limit"):
+ record[corruption.removesuffix("_limit")] *= 51
+ elif corruption.startswith("include_"):
+ item = record["includes"][0]
+ if corruption == "include_encoding":
+ item["literal_b64"] = "invalid!"
+ elif corruption == "include_kind":
+ item["kind"] = "dynamic"
+ elif corruption == "include_span":
+ item["span"]["end_row"] = item["span"]["start_row"]
+ item["span"]["end_column"] += 1
+ elif corruption == "include_position":
+ item["end_byte"] += 1
+ elif corruption == "include_bounds":
+ item["span"]["end_byte"] = record["source_size"] + 1
+ else:
+ import base64
+ item["literal_b64"] = base64.b64encode(b"/foreign.hpp").decode()
+ else:
+ item = record["shadows"][0]
+ if corruption in {"shadow_encoding", "shadow_scope"}:
+ item["scope_b64"] = "invalid!" if corruption == "shadow_encoding" else "YmFkLXNjb3Bl"
+ elif corruption == "shadow_role":
+ item["class_scope"] = 1
+ else:
+ item["start_byte"] += 1
+
+
+@pytest.mark.parametrize("boundary", ["membership", "containment"])
+@pytest.mark.parametrize("corruption", CORRUPTIONS)
+def test_req_qml020_ac02_cpp_file_transport_cannot_authorize_corrupted_file_role(tmp_path, boundary, corruption):
+ """Foreign/duplicate/semantic/ill-typed file evidence creates no guessed endpoint."""
+ result = cpp_fixture(tmp_path)
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ target = file_node(nodes)
+ declarations = [next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source"
+ and qml_metadata(node).get("source_kind") == "cpp")]
+ corrupt(nodes, target, corruption)
+ borrowed, before = list(nodes), copy.deepcopy(nodes)
+ target_id, reason = project(tmp_path, nodes, edges, declarations, boundary)
+ assert not target_id
+ if boundary == "membership":
+ assert reason in {"membership_target_outside_accepted_corpus", "duplicate_provider"}
+ assert borrowed == before
+
+
+@pytest.mark.parametrize("complete", [False, True])
+def test_req_qml008_ac02_cpp_file_role_preserves_separate_constructor_type_authority(tmp_path, complete):
+ """A valid file record can contain sites while incomplete type proof stays unusable."""
+ from graphify.extractors.cpp_constructor_type_authority import ConstructorTypeAuthority, valid_source_transport
+ from graphify.qt_source_file_role import generic_file_role
+
+ result = cpp_fixture(tmp_path)
+ target = file_node(result["nodes"])
+ target["metadata"]["cpp_constructor_types"]["complete"] = complete
+ record = target["metadata"]["cpp_constructor_types"]
+ before = copy.deepcopy(target)
+ assert valid_source_transport(record)
+ assert generic_file_role(target, "backend.cpp")
+ authority = ConstructorTypeAuthority([target])
+ assert bool(authority._fact("backend.cpp")) is complete
+ assert target == before
+
+
+@pytest.mark.parametrize("boundary", ["membership", "containment"])
+def test_req_qml020_ac02_cpp_file_joins_read_no_source_and_execute_no_corpus(tmp_path, monkeypatch, boundary):
+ """After real extraction, both joins use accepted dictionaries only."""
+ import builtins
+ import subprocess
+ from pathlib import Path
+ result = cpp_fixture(tmp_path)
+ declarations = [node for node in facts(result, "qt_source") if qml_metadata(node)["source_kind"] == "cpp"]
+ target = file_node(result["nodes"])
+
+ def forbidden(*_args, **_kwargs):
+ raise AssertionError("File identity must not read or execute corpus sources")
+
+ for method in ("open", "read_bytes", "read_text", "glob", "rglob"):
+ monkeypatch.setattr(Path, method, forbidden)
+ monkeypatch.setattr(builtins, "open", forbidden)
+ monkeypatch.setattr(subprocess, "run", forbidden)
+ monkeypatch.setattr(subprocess, "Popen", forbidden)
+ assert project(tmp_path, result["nodes"], result["edges"], declarations, boundary)[0] == target["id"]
diff --git a/tests/test_qt_cpp_owner_upgrade.py b/tests/test_qt_cpp_owner_upgrade.py
new file mode 100644
index 0000000000..b480da16cb
--- /dev/null
+++ b/tests/test_qt_cpp_owner_upgrade.py
@@ -0,0 +1,91 @@
+"""Native ownership policy upgrades refresh real unchanged-input CLI products."""
+from __future__ import annotations
+
+import pytest
+
+import graphify.cache as cache
+import graphify.extract as extraction
+import graphify.qt_incremental as policy
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_analysis_state import QT_STATE_FILE, inspect_qt_analysis
+from tests.test_qt_cpp_upgrade_invalidation import cli, unchanged_python
+
+
+SOURCES = {
+ "backend.hpp": "namespace Shared {\nclass Backend : public QObject {\n Q_OBJECT\n public: void run();\n signals: void changed();\n};\n}\n",
+ "backend.cpp": '#include "backend.hpp"\nusing namespace Shared;\nvoid Backend::run(){ emit changed(); }\n',
+ # This forward is Qt-admitted by its unrelated engine declaration. It must
+ # remain a fact without competing with the actual complete Backend class.
+ "forward.hpp": "namespace Shared { class Backend; }\nQQmlApplicationEngine *engine;\n",
+ "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n",
+}
+
+
+def prior_epoch(root, monkeypatch):
+ """Publish a real accepted graph/stamp under the previous policy only.
+
+ Extraction and graph persistence are production code. The old policy stamp
+ models the upgrade boundary; mapping regressions separately reproduce the
+ original orphan facts before correction, without duplicating old analyzers.
+ """
+ paths = []
+ for name, content in SOURCES.items():
+ path = root / name
+ path.write_text(content, encoding="utf-8")
+ paths.append(path)
+ with monkeypatch.context() as historical:
+ historical.setattr(policy, "QT_POLICY_VERSION", 2)
+ graph = cli(root, historical, "extract")
+ assert not inspect_qt_analysis(root, root / "graphify-out", paths).changed
+ return paths, graph
+
+
+@pytest.mark.parametrize("operation", ["update", "extract"])
+def test_qml008_ac02_policy_three_reparses_unchanged_native_ownership(tmp_path, monkeypatch, operation):
+ """Old policy cannot skip parsing; current facts and unrelated Python survive reload."""
+ monkeypatch.chdir(tmp_path)
+ paths, before = prior_epoch(tmp_path, monkeypatch)
+ sources = {path: path.read_bytes() for path in paths}
+ version = cache._EXTRACTOR_VERSION
+ assert inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed
+ observed, real = [], extraction._safe_extract_with_xaml_root
+
+ def observe(extractor, path, root):
+ observed.append(path)
+ return real(extractor, path, root)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ current = cli(tmp_path, monkeypatch, operation)
+ assert set(paths[:3]).issubset(observed)
+ assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed
+ members = [(identity, qt_metadata(data)) for identity, data in current.nodes(data=True)
+ if qt_metadata(data).get("kind") == "member"]
+ emissions = [(identity, qt_metadata(data)) for identity, data in current.nodes(data=True)
+ if qt_metadata(data).get("kind") == "emission"]
+ assert members and emissions
+ assert all(metadata["class_id"] and current.degree(identity) for identity, metadata in members)
+ assert all(metadata["owner_id"] and metadata["status"] == "resolved" and current.degree(identity)
+ for identity, metadata in emissions)
+ assert unchanged_python(before) == unchanged_python(current)
+ assert sources == {path: path.read_bytes() for path in paths} and version == cache._EXTRACTOR_VERSION
+ stable = cli(tmp_path, monkeypatch, operation)
+ assert unchanged_python(stable) == unchanged_python(current)
+ assert {identity for identity, _ in stable.nodes(data=True)} == set(current)
+
+
+@pytest.mark.parametrize("force", [False, True])
+def test_qml016_ac04_failed_owner_upgrade_retains_prior_graph_stamp_and_manifest(tmp_path, monkeypatch, force):
+ """A genuine native syntax failure cannot replace prior accepted policy-two products."""
+ monkeypatch.chdir(tmp_path)
+ paths, _ = prior_epoch(tmp_path, monkeypatch)
+ out = tmp_path / "graphify-out"
+ products = [out / "graph.json", out / "manifest.json", out / QT_STATE_FILE, out / ".graphify_root"]
+ before = {path: path.read_bytes() for path in products}
+ paths[1].write_text('void Backend::run( { emit changed(); }\n', encoding="utf-8")
+ with pytest.raises(SystemExit) as failure:
+ cli(tmp_path, monkeypatch, "update", force=force)
+ assert failure.value.code not in (0, None)
+ assert before == {path: path.read_bytes() for path in products}
+ paths[1].write_text(SOURCES["backend.cpp"], encoding="utf-8")
+ recovered = cli(tmp_path, monkeypatch, "update")
+ assert recovered and not inspect_qt_analysis(tmp_path, out, paths).changed
diff --git a/tests/test_qt_cpp_syntax.py b/tests/test_qt_cpp_syntax.py
new file mode 100644
index 0000000000..3a9dde0fa3
--- /dev/null
+++ b/tests/test_qt_cpp_syntax.py
@@ -0,0 +1,137 @@
+"""Original-byte Qt recovery, false-positive rejection and bounded failure."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp, is_qt_cpp_source
+from graphify.extractors.qt_cpp_mapping import map_cpp
+from graphify.extractors.qt_cpp_syntax import QtCppError, lexical_code, read_cpp, source_span
+from tests.qt_cpp_test_helpers import collect, facts, write
+from graphify.extractors.qt_cpp_facts import qt_metadata
+
+
+@pytest.mark.parametrize("section,access", [("public Q_SLOTS", "public"), ("protected Q_SLOTS", "protected"),
+ ("private Q_SLOTS", "private"), ("Q_SLOTS", "public"),
+ ("Q_SIGNALS", "public"), ("signals", "public")])
+def test_access_sections_keep_original_roles_offsets(tmp_path, section, access):
+ source = f'class B : public QObject {{ Q_OBJECT {section}: void changed(int x); }};'
+ path = write(tmp_path, "b.hpp", source)
+ result = collect(tmp_path, {"b.hpp": source})
+ member = qt_metadata(facts(result, "member", "changed")[0])
+ assert member["access"] == access
+ assert member["roles"] == (["signal"] if section in {"signals", "Q_SIGNALS"} else ["slot"])
+ span = member["span"]
+ assert path.read_bytes()[span["start_byte"]:span["end_byte"]] == b"void changed(int x);"
+ unit = read_cpp(path, tmp_path)
+ assert len(unit.source) == len(unit.parsed_source) == len(unit.code)
+
+
+def test_annotations_legacy_calls_and_ordinary_emit_are_distinct(tmp_path):
+ source = '''class B : public QObject { Q_OBJECT public: Q_SIGNAL void changed(int); Q_SLOT void accept(int); };
+void emit() {} void install(B *s,B *r) {
+QObject::connect(s, SIGNAL(changed(int)), r, SLOT(accept(int)));
+emit(); emit s->changed(1); Q_EMIT r->changed(2);
+}'''
+ unit = read_cpp(write(tmp_path, "calls.cpp", source), tmp_path)
+ assert not unit.tree.has_error
+ assert b"void emit()" in unit.parsed_source and b"emit();" in unit.parsed_source
+ assert b"emit s" not in unit.parsed_source
+ assert unit.source == source.encode()
+ result = collect(tmp_path, {"calls.cpp": source})
+ assert qt_metadata(facts(result, "member", "changed")[0])["roles"] == ["signal"]
+ assert qt_metadata(facts(result, "member", "accept")[0])["roles"] == ["slot"]
+
+
+def test_comments_strings_raw_literals_and_preprocessor_definitions_are_inert(tmp_path):
+ source = '''// QML_ELEMENT Q_OBJECT
+/* Q_PROPERTY(int bad READ bad) */
+const char *a = "QML_NAMED_ELEMENT(Fake)";
+const char *b = R"tag(QML_ELEMENT // Q_OBJECT)tag";
+#define DECLARE_QML QML_ELEMENT
+class Ordinary {};
+'''
+ path = write(tmp_path, "ordinary.cpp", source)
+ unit = read_cpp(path, tmp_path)
+ assert unit.macros == []
+ assert lexical_code(source.encode()).count(b"\n") == source.count("\n")
+ result = collect(tmp_path, {"ordinary.cpp": source})
+ assert facts(result, "registration") == []
+ assert not is_qt_cpp_source(write(tmp_path, "strings.cpp", 'const char *s = "QObject QML_ELEMENT";'))
+
+
+def test_unicode_crlf_macro_spans_are_original_bytes(tmp_path):
+ source = '// café ☃\r\nclass B : public QObject {\r\nQ_OBJECT\r\nQML_NAMED_ELEMENT(B)\r\n};\r\n'
+ unit = read_cpp(write(tmp_path, "B.hpp", source), tmp_path)
+ macro = next(item for item in unit.macros if item["name"] == "QML_NAMED_ELEMENT")
+ start = source.encode().index(b"QML_NAMED_ELEMENT")
+ assert macro["span"] == source_span(source.encode(), start, start + len(b"QML_NAMED_ELEMENT(B)"))
+ assert macro["span"]["start_row"] == 3 and macro["span"]["start_column"] == 0
+ assert unit.parsed_source.count(b"\r\n") == 5
+
+
+@pytest.mark.parametrize("source", ["class B { Q_OBJECT Q_PROPERTY(int bad", "class B : QObject { Q_OBJECT void broken( };", "\xff"])
+def test_invalid_qt_source_is_explicit_failure_without_authoritative_overlay(tmp_path, source):
+ path = tmp_path / "bad.hpp"
+ path.write_bytes(source.encode() if source != "\xff" else b"Q_OBJECT \xff")
+ result = {"nodes": [], "edges": []}
+ enrich_qt_cpp([path], [result], root=tmp_path)
+ assert result["nodes"] == result["edges"] == []
+ assert result["qml_failures"][0]["code"] in {"QT_CPP_SYNTAX", "QT_CPP_READ"}
+ assert str(tmp_path) not in str(result["diagnostics"])
+
+
+def test_non_qt_malformed_cpp_is_not_new_failure(tmp_path):
+ path = write(tmp_path, "ordinary.cpp", "void unfinished(")
+ result = {"nodes": [], "edges": []}
+ enrich_qt_cpp([path], [result], root=tmp_path)
+ assert "qml_failures" not in result
+
+
+def test_read_limits_and_root_rejection(tmp_path, monkeypatch):
+ path = write(tmp_path, "large.hpp", "Q_OBJECT " * 20)
+ monkeypatch.setattr("graphify.extractors.qt_cpp_syntax.MAX_CPP_BYTES", 10)
+ with pytest.raises(QtCppError, match="5 MB") as failure:
+ read_cpp(path, tmp_path)
+ assert failure.value.code == "QT_CPP_LIMIT"
+ with pytest.raises(QtCppError) as outside:
+ read_cpp(path, tmp_path / "other")
+ assert outside.value.code == "QT_CPP_ROOT"
+
+
+def test_missing_canonical_ids_are_not_reconstructed(tmp_path):
+ unit = read_cpp(write(tmp_path, "b.hpp", "class B: public QObject { Q_OBJECT public: Q_INVOKABLE void go(void); };"), tmp_path)
+ mapping = map_cpp(unit, [], [], root=tmp_path)
+ assert mapping.classes[0]["node_id"] == ""
+ assert mapping.functions[0]["node_id"] == ""
+ assert mapping.functions[0]["signature"] == "go()"
+
+
+def test_explicit_member_pointer_cast_recovery_preserves_source_signature(tmp_path):
+ source = 'void install(){ QObject::connect(s, static_cast(&Sender::changed), r, &Receiver::accept); }'
+ unit = read_cpp(write(tmp_path, "cast.cpp", source), tmp_path)
+ assert not unit.tree.has_error and unit.source == source.encode() and unit.code == source.encode()
+ pointer = b"&Sender::changed"
+ offset = source.encode().index(pointer)
+ assert unit.parsed_source[offset:offset + len(pointer)] == pointer
+ assert len(unit.parsed_source) == len(source.encode())
+ assert b"static_cast" not in unit.parsed_source
+
+
+def test_overlay_root_rejection_happens_before_admission_read(tmp_path, monkeypatch):
+ path = tmp_path / "outside.hpp"
+ path.write_text("class B { Q_OBJECT };", encoding="utf-8")
+ def fail_read(_):
+ raise AssertionError("out-of-root source was read")
+ monkeypatch.setattr("graphify.extractors.qt_cpp_exposure.is_qt_cpp_source", fail_read)
+ result = {"nodes": [], "edges": []}
+ enrich_qt_cpp([path], [result], root=tmp_path / "allowed")
+ assert result["qml_failures"][0]["code"] == "QT_CPP_ROOT"
+ assert result["nodes"] == result["edges"] == []
+
+
+def test_reference_pointer_parameter_names_are_not_part_of_type(tmp_path):
+ result = collect(tmp_path, {"b.hpp": 'class B: public QObject { Q_OBJECT public: void accept(const int &value, QObject *object, int &&moved); };'})
+ member = qt_metadata(facts(result, "member", "accept")[0])
+ assert member["parameter_names"] == ["value", "object", "moved"]
+ assert member["parameter_types"] == ["const int&", "QObject*", "int&&"]
+ assert member["signature"] == "accept(const int&,QObject*,int&&)"
diff --git a/tests/test_qt_cpp_upgrade_invalidation.py b/tests/test_qt_cpp_upgrade_invalidation.py
new file mode 100644
index 0000000000..1c46819074
--- /dev/null
+++ b/tests/test_qt_cpp_upgrade_invalidation.py
@@ -0,0 +1,222 @@
+"""REQ-QML-018-AC02 same-version upgrades retire stale syntax and analysis epochs."""
+from __future__ import annotations
+
+import pytest
+
+import graphify.__main__ as entrypoint
+import graphify.cache as cache
+import graphify.extract as extraction
+import graphify.qt_incremental as policy
+from graphify.qt_analysis_state import commit_qt_analysis, inspect_qt_analysis
+from graphify.build import build_from_json
+from graphify.detect import save_manifest
+from graphify.export import to_json
+from tests.test_qt_final_incremental_parity import clean, normalized, published
+
+
+CPP = {
+ "unused": "int helper(){return 1;}\nvoid use(){ Q_UNUSED(helper()); }\n",
+ "default": "struct Value {};\nvoid use(const Value &value = {}) {}\n",
+}
+
+
+def source(root, syntax):
+ path = root / "adapter.cpp"
+ path.write_text(CPP[syntax], encoding="utf-8")
+ return path
+
+
+def old_syntax(path):
+ # This is the real generic parser before the Qt-normalization adapter,
+ # producing the historical accepted cache shape from the same source bytes.
+ return extraction._extract_generic(path, extraction._CPP_CONFIG)
+
+
+@pytest.mark.parametrize("syntax", CPP)
+def test_req_qml018_ac02_schema_retires_real_same_package_syntax_cache(tmp_path, monkeypatch, syntax):
+ """The fork upgrade changes extractor code without changing package version."""
+ path = source(tmp_path, syntax)
+ before = path.read_bytes(), cache._EXTRACTOR_VERSION
+ old = old_syntax(path)
+ with monkeypatch.context() as historical:
+ historical.setattr(cache, "_AST_CACHE_SCHEMA", 5)
+ cache.save_cached(path, old, root=tmp_path)
+ old_dir = cache.cache_dir(tmp_path)
+ assert cache.load_cached(path, root=tmp_path) is not None
+ assert cache.load_cached(path, root=tmp_path) is None
+ assert not old_dir.exists()
+ assert before == (path.read_bytes(), cache._EXTRACTOR_VERSION)
+ assert cache.cache_dir(tmp_path).name.endswith(f"-s{cache._AST_CACHE_SCHEMA}")
+
+
+def test_req_qml018_ac02_policy_epoch_refreshes_same_package_unchanged_cpp(tmp_path, monkeypatch):
+ """Only the analysis policy changes; source and installed package set do not."""
+ path = source(tmp_path, "unused")
+ out = tmp_path / "graphify-out"
+ with monkeypatch.context() as historical:
+ historical.setattr(policy, "QT_POLICY_VERSION", 1)
+ old = inspect_qt_analysis(tmp_path, out, [path])
+ commit_qt_analysis(out, old)
+ current = inspect_qt_analysis(tmp_path, out, [path])
+ assert current.changed and current.fingerprint != old.fingerprint
+ assert current.has_qt and current.current_has_qt
+
+
+@pytest.mark.parametrize("text", [CPP["unused"], CPP["default"],
+ "void use(const T &value = { /* empty */ }) {}"])
+def test_req_qml018_ac02_new_syntax_candidates_reparse_only_accepted_cpp(tmp_path, text):
+ """Discovery remains caller-owned; no adjacent source or root is created."""
+ path = tmp_path / "adapter.cpp"
+ path.write_text(text, encoding="utf-8")
+ before = set(tmp_path.iterdir())
+ assert policy.requires_native_refresh([path])
+ assert set(tmp_path.iterdir()) == before
+
+
+def test_req_qml018_ac02_unrelated_plain_cpp_retains_actual_warm_cache(tmp_path, monkeypatch):
+ """An ordinary call/nonempty initializer keeps the established AST fast path."""
+ path = tmp_path / "ordinary.cpp"
+ path.write_text("void helper(){} void use(){ int values[] = {1,2}; helper(); }", encoding="utf-8")
+ assert not policy.requires_native_refresh([path])
+ first = extraction.extract([path], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert cache.load_cached(path, root=tmp_path)
+ observed, real = [], extraction._safe_extract_with_xaml_root
+
+ def observe(extractor, input_path, root):
+ observed.append(input_path)
+ return real(extractor, input_path, root)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ warm = extraction.extract([path], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not observed and warm == first
+
+
+def historical_publication(root, monkeypatch, syntax):
+ """Publish actual pre-adapter parser output/cache with the former policy epoch.
+
+ Generic builder/export and manifest/stamp APIs produce the historical accepted
+ state; current update/extract functions are never replaced by fixture fakes.
+ """
+ path = source(root, syntax)
+ keep = root / "keep.py"
+ keep.write_text("def helper(): return 7\n\ndef retained(): return helper()\n", encoding="utf-8")
+ paths, out = [path, keep], root / "graphify-out"
+ with monkeypatch.context() as historical:
+ historical.setattr(cache, "_AST_CACHE_SCHEMA", 5)
+ historical.setattr(policy, "QT_POLICY_VERSION", 1)
+ old = old_syntax(path)
+ cache.save_cached(path, old, root=root)
+ assert cache.load_cached(path, root=root)
+ unchanged = extraction.extract([keep], root=root, cache_root=root, parallel=False)
+ graph = build_from_json({"nodes": [*old["nodes"], *unchanged["nodes"]],
+ "edges": [*old["edges"], *unchanged["edges"]]}, root=root)
+ assert to_json(graph, {}, str(out / "graph.json"), force=True)
+ save_manifest({"code": [str(item) for item in paths]}, manifest_path=str(out / "manifest.json"),
+ kind="ast", root=root, scan_corpus={str(item) for item in paths})
+ state = inspect_qt_analysis(root, out, paths)
+ commit_qt_analysis(out, state)
+ assert not inspect_qt_analysis(root, out, paths).changed
+ old_dir = cache.cache_dir(root)
+ return paths, state.fingerprint, old_dir
+
+
+def cli(root, monkeypatch, operation, *, force=False):
+ # These documented production options isolate skill maintenance and tips;
+ # the real console parser, locking, update/extract and persistence all run.
+ monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", "1")
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ args = ["graphify", operation, str(root), "--no-cluster"]
+ if operation == "extract":
+ args += ["--update", "--code-only"]
+ if force:
+ args.append("--force")
+ monkeypatch.setattr(entrypoint.sys, "argv", args)
+ try:
+ entrypoint.main()
+ except SystemExit as result:
+ if result.code not in (None, 0):
+ raise
+ return published(root)
+
+
+def unchanged_python(graph):
+ nodes, edges = normalized(graph)
+ ids = {identity for identity, data in graph.nodes(data=True) if data.get("source_file") == "keep.py"}
+ return {identity: nodes[identity] for identity in ids}, {edge for edge in edges if edge[0] in ids and edge[1] in ids}
+
+
+@pytest.mark.parametrize("operation", ["update", "extract"])
+@pytest.mark.parametrize("syntax", CPP)
+def test_req_qml018_ac02_actual_cli_refreshes_old_epoch_without_source_or_package_change(
+ tmp_path, monkeypatch, operation, syntax
+):
+ """Upgrade success: stale accepted graph/cache cannot survive unchanged inputs."""
+ monkeypatch.chdir(tmp_path)
+ paths, old_fingerprint, old_dir = historical_publication(tmp_path, monkeypatch, syntax)
+ before = {item.name: item.read_bytes() for item in paths}, cache._EXTRACTOR_VERSION
+ initial = published(tmp_path)
+ untouched = unchanged_python(initial)
+ observed, real = [], extraction._safe_extract_with_xaml_root
+
+ def observe(extractor, input_path, root):
+ observed.append(input_path)
+ return real(extractor, input_path, root)
+
+ # Observe the genuine parser dispatch, rather than infer it from a graph
+ # that could already retain some nested known calls under the old adapter.
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ current = cli(tmp_path, monkeypatch, operation)
+ assert paths[0] in observed
+ assert not old_dir.exists() and cache.cache_dir(tmp_path).name.endswith(f"-s{cache._AST_CACHE_SCHEMA}")
+ assert cache.load_cached(paths[0], root=tmp_path) is None
+ assert cache.load_cached(paths[1], root=tmp_path) is not None
+ state = inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths)
+ assert not state.changed and state.fingerprint != old_fingerprint
+ assert before == ({item.name: item.read_bytes() for item in paths}, cache._EXTRACTOR_VERSION)
+ assert unchanged_python(current) == untouched
+ if syntax == "unused":
+ nodes = {data.get("label"): identity for identity, data in current.nodes(data=True)
+ if data.get("source_file") == "adapter.cpp"}
+ pairs = {nodes["use()"], nodes["helper()"]}
+ assert any({source, target} == pairs and data.get("relation") == "calls"
+ for source, target, data in current.edges(data=True))
+ assert any(call["callee"] == "Q_UNUSED" for call in old_syntax(paths[0])["raw_calls"])
+ direct = extraction.extract_cpp(paths[0])
+ assert not direct.get("parse_errors")
+ assert not any(call["callee"] == "Q_UNUSED" for call in direct.get("raw_calls", []))
+ assert normalized(current) == normalized(clean(tmp_path, tmp_path / ".cold-cache"))
+ assert normalized(current) == normalized(clean(tmp_path, tmp_path / ".cold-cache"))
+ graph_bytes = (tmp_path / "graphify-out/graph.json").read_bytes()
+ repeated = cli(tmp_path, monkeypatch, operation)
+ assert normalized(repeated) == normalized(current)
+ assert (tmp_path / "graphify-out/graph.json").read_bytes() == graph_bytes
+
+
+def products(root):
+ out = root / "graphify-out"
+ return {name: (out / name).read_bytes() for name in ("graph.json", "manifest.json", ".qt_analysis.json")}
+
+
+def ast_entries(root):
+ base = root / "graphify-out/cache/ast"
+ return {path.relative_to(base).as_posix(): path.read_bytes() for path in base.rglob("*.json")}
+
+
+@pytest.mark.parametrize("force", [False, True])
+def test_req_qml018_ac02_current_epoch_failure_keeps_real_cache_and_products(tmp_path, monkeypatch, force):
+ """Malformed new input cannot poison current syntax cache or published state."""
+ monkeypatch.chdir(tmp_path)
+ path = source(tmp_path, "unused")
+ valid = path.read_bytes()
+ accepted = cli(tmp_path, monkeypatch, "update")
+ cache.save_cached(path, extraction.extract_cpp(path), root=tmp_path)
+ before, cached = products(tmp_path), ast_entries(tmp_path)
+ assert cached and all(f"-s{cache._AST_CACHE_SCHEMA}/" in name for name in cached)
+ path.write_text("void use(){ Q_UNUSED(helper()) broken( }", encoding="utf-8")
+ with pytest.raises(SystemExit) as failure:
+ cli(tmp_path, monkeypatch, "update", force=force)
+ assert failure.value.code == 1
+ assert products(tmp_path) == before and ast_entries(tmp_path) == cached
+ path.write_bytes(valid)
+ assert normalized(cli(tmp_path, monkeypatch, "update")) == normalized(accepted)
+ assert ast_entries(tmp_path) == cached
diff --git a/tests/test_qt_cross_event_occurrences.py b/tests/test_qt_cross_event_occurrences.py
new file mode 100644
index 0000000000..5c6625f40e
--- /dev/null
+++ b/tests/test_qt_cross_event_occurrences.py
@@ -0,0 +1,175 @@
+"""INC-QML-37: independent cross-language event occurrences keep bounded metadata."""
+from __future__ import annotations
+
+import json
+import os
+import stat
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis
+from tests.test_qt_access_providers import BACKEND, QML
+
+
+def sources(root, count, *, mixed=True):
+ """Each actual source call alternates native/QML direction and operation."""
+ statements = []
+ for index in range(count):
+ operation = "disconnect" if mixed and index % 4 >= 2 else "connect"
+ parameters = ('root, SIGNAL(closed()), backend, SLOT(close())' if index % 2 == 0
+ else 'backend, SIGNAL(changed()), root, SLOT(refresh())')
+ statements.append(f' QObject::{operation}({parameters});\n')
+ source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view;
+ view.setSource(QUrl("{(root / 'Main.qml').as_uri()}")); auto root = view.rootObject();
+''' + ''.join(statements) + '}\n'
+ return {"access.cpp": source, "Main.qml": QML,
+ "keep.py": "def helper(): return 7\ndef untouched(): return helper()\n"}
+
+
+def assert_events(graph, count):
+ """Site-owned role nodes and edges retain their own operation/direction/span."""
+ events = [(identity, node, qt_metadata(node)) for identity, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") in {"connect", "disconnect"}]
+ assert len(events) == count
+ for identity, node, md in events:
+ assert md["status"] == "resolved", md
+ endpoints = [(role_id, role, qt_metadata(role)) for role_id, role in graph.nodes(data=True)
+ if qt_metadata(role).get("kind") == "event_endpoint"
+ and qt_metadata(role).get("owner_id") == identity]
+ assert {values["role"] for _, _, values in endpoints} == {"signal", "receiver"}
+ for role_id, role, values in endpoints:
+ assert values["span"] == md["span"] and role["source_location"] == node["source_location"]
+ assert values["bridge_direction"] == md["bridge_direction"]
+ assert graph.has_edge(identity, role_id)
+ target = values["target_id"]
+ assert graph.has_edge(role_id, target)
+ available = graph.get_edge_data(role_id, target)
+ available = list(available.values()) if graph.is_multigraph() else [available]
+ matching = [edge for edge in available
+ if edge.get("context") == f'qt_qml_{md["kind"]}_{values["role"]}']
+ assert len(matching) == 1
+ edge = matching[0]
+ proof = qt_metadata(edge)
+ assert edge["relation"] == "references"
+ assert edge["context"] == f'qt_qml_{md["kind"]}_{values["role"]}'
+ assert proof["bridge_direction"] == md["bridge_direction"] and proof["span"] == md["span"]
+ assert not any(key.startswith("raw_values/") for key in edge["metadata"]["qt"]["raw_values"])
+ assert any(node.get("source_file") == "keep.py" for _, node in graph.nodes(data=True))
+
+
+@pytest.mark.parametrize("count", [1, 2, 3, 4, 8, 16])
+@pytest.mark.parametrize("mixed", [False, True])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml016_ac02_ac04_cross_language_occurrences_keep_own_transport(tmp_path, count, mixed, directed):
+ """Real facade/build/writer/reload retain every repeated connect/disconnect occurrence."""
+ result = analysis(tmp_path, sources(tmp_path, count, mixed=mixed))
+ # Decode every producer edge before graph assembly; repeated encoding cannot
+ # quietly erase an earlier valid endpoint through the projection guard.
+ for edge in result["edges"]:
+ qt_metadata(edge)
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ assert_events(graph, count)
+ output = tmp_path / "accepted.json"
+ assert to_json(graph, {}, str(output), force=True)
+ assert_events(load_node_link_graph(json.loads(output.read_bytes())), count)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac04_cross_event_edits_removal_and_repeat_match_clean(tmp_path, monkeypatch, operation):
+ """Real C++-only and QML-only edits retire stale endpoints and preserve unrelated code."""
+ from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+ analysis(tmp_path, sources(tmp_path, 8))
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert_events(initial, 8)
+ assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold-cache"))
+ other = unrelated(initial)
+ cpp = tmp_path / "access.cpp"
+ original = cpp.read_bytes()
+ statements = original.splitlines(keepends=True)
+ cpp.write_bytes(b''.join(line for line in statements if b'QObject::disconnect' not in line))
+ edited = run(tmp_path, monkeypatch, operation, [cpp])
+ assert_events(edited, 4)
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited-cache"))
+ assert unrelated(edited) == other
+ qml = tmp_path / "Main.qml"
+ original_qml = qml.read_bytes()
+ qml.write_bytes(original_qml.replace(b'signal closed()', b'signal stopped()'))
+ changed = run(tmp_path, monkeypatch, operation, [qml])
+ assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".qml-cache"))
+ assert unrelated(changed) == other
+ qml.write_bytes(original_qml)
+ cpp.write_bytes(original)
+ restored = run(tmp_path, monkeypatch, operation, [cpp, qml])
+ assert_events(restored, 8)
+ assert normalized(restored) == normalized(initial)
+ output = tmp_path / "graphify-out/graph.json"
+ before = output.read_bytes(), output.stat().st_mtime_ns
+ assert normalized(run(tmp_path, monkeypatch, operation)) == normalized(restored)
+ assert (output.read_bytes(), output.stat().st_mtime_ns) == before
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac03_event_policy_refreshes_unchanged_source(tmp_path, monkeypatch, operation):
+ """The previous policy checkpoint cannot authorize the new derived event contract."""
+ import graphify.qt_incremental as policy
+ from tests.test_qt_final_incremental_parity import normalized, run
+
+ analysis(tmp_path, sources(tmp_path, 4))
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ with monkeypatch.context() as older:
+ older.setattr(policy, "QT_POLICY_VERSION", policy.QT_POLICY_VERSION - 1)
+ prior = run(tmp_path, monkeypatch, operation)
+ checkpoint = tmp_path / "graphify-out/.qt_analysis.json"
+ old_bytes = checkpoint.read_bytes()
+ refreshed = run(tmp_path, monkeypatch, operation, [])
+ assert checkpoint.read_bytes() != old_bytes
+ assert normalized(refreshed) == normalized(prior)
+ assert_events(refreshed, 4)
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary")
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac06_cross_event_publication_failure_retains_prior_and_retries(tmp_path, monkeypatch, operation):
+ """Actual destination failure preserves accepted events/cache before successful repaired retry."""
+ from graphify.cache import cache_dir
+ from graphify.watch import _rebuild_code
+ from tests.test_qt_final_incremental_parity import clean, normalized, run
+ from tests.test_qt_readonly_publication import durable
+
+ analysis(tmp_path, sources(tmp_path, 4))
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ accepted = durable(tmp_path)
+ cached = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")}
+ assert cached
+ analysis(tmp_path, sources(tmp_path, 8))
+ cpp = tmp_path / "access.cpp"
+ target = tmp_path / "graphify-out/graph.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, monkeypatch, operation, [cpp])
+ assert rejected.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True)
+ assert durable(tmp_path) == accepted
+ assert all(path.read_bytes() == data for path, data in cached.items())
+ assert_events(initial, 4)
+ finally:
+ target.chmod(stat.S_IWRITE)
+ repaired = run(tmp_path, monkeypatch, operation, [cpp])
+ assert_events(repaired, 8)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired-cache"))
+ accepted = durable(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation)) == normalized(repaired)
+ assert durable(tmp_path) == accepted
diff --git a/tests/test_qt_declared_api_shapes.py b/tests/test_qt_declared_api_shapes.py
new file mode 100644
index 0000000000..c32ae61e2b
--- /dev/null
+++ b/tests/test_qt_declared_api_shapes.py
@@ -0,0 +1,174 @@
+"""REQ-QML-018-AC03/AC04/AC06: original C++ declarator shape bounds API authority."""
+from __future__ import annotations
+
+import copy
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.qt_qml_projection import allows_qt_qml_edge
+from tests.qt_adoption_fixture import NATIVE, sources
+from tests.qt_analysis_helpers import analysis, sites
+
+
+@pytest.mark.parametrize("declaration", [
+ "Backend **makeBackend()", "Backend *&makeBackend()", "Backend &&makeBackend()",
+ "Backend makeBackend()", "Backend (*makeBackend())[2]", "Backend (*makeBackend())()",
+])
+def test_req_qml018_ac03_factory_unsupported_shape_cannot_lend_api(tmp_path, declaration):
+ """Valid syntax with multiple indirections, values or compound returns stays opaque."""
+ native = NATIVE.replace("Backend *makeBackend()", declaration)
+ result = analysis(tmp_path, sources(tmp_path, native=native))
+ assert not sites(result, "context_binding")
+
+
+@pytest.mark.parametrize("declaration", [
+ "Backend **backend", "Backend *&backend", "Backend backend[2]",
+ "Backend (*backend)()", "Backend backend",
+])
+def test_req_qml018_ac03_field_unsupported_shape_cannot_lend_api(tmp_path, declaration):
+ """A field's named base class alone does not establish one object endpoint."""
+ native = NATIVE.replace("Backend *backend", declaration)
+ result = analysis(tmp_path, sources(tmp_path, "factory->backend", native=native))
+ assert not sites(result, "context_binding")
+
+
+@pytest.mark.parametrize("raw,getter", [
+ ("Service**", "Service **"), ("Service*&", "Service *&"),
+ ("Service&&", "Service &&"), ("Service", "Service "),
+])
+def test_req_qml018_ac04_child_unsupported_shape_cannot_lend_signal(tmp_path, raw, getter):
+ """Matching property/getter base classes cannot erase unsupported pointer depth."""
+ native = NATIVE.replace("Q_PROPERTY(Service* service", "Q_PROPERTY(" + raw + " service")
+ native = native.replace("Service *service()", getter + "service()")
+ result = analysis(tmp_path, sources(tmp_path, native=native))
+ assert len(sites(result, "context_binding")) == 1
+ by_id = {node["id"]: node for node in result["nodes"]}
+ assert not any(qt_metadata(by_id[qt_metadata(node)["endpoint_proof"]["class_id"]]).get("class_name") == "Service"
+ for node in sites(result, "context_access"))
+ assert not sites(result, "context_subscription")
+
+
+@pytest.mark.parametrize("declaration,expression", [
+ ("Factory **factory", "factory->makeBackend()"),
+ ("Factory *&factory", "factory->makeBackend()"),
+ ("Factory *factory[2]", "factory->makeBackend()"),
+ ("Factory (*factory)()", "factory->makeBackend()"),
+ ("Factory factory", "factory->makeBackend()"),
+ ("Factory *factory", "factory.makeBackend()"),
+])
+def test_req_qml018_ac03_root_shape_must_match_literal_member_operator(tmp_path, declaration, expression):
+ """The selected lexical declaration retains its shape before member traversal."""
+ inputs = sources(tmp_path, expression)
+ inputs["access.cpp"] = inputs["access.cpp"].replace("Factory *factory", declaration)
+ assert not sites(analysis(tmp_path, inputs), "context_binding")
+
+
+@pytest.mark.parametrize("root,expression,returned", [
+ ("Factory *const factory", "factory->makeBackend()", "Backend * const"),
+ ("const Factory *factory", "factory->makeBackend()", "const Backend *"),
+ ("Factory &factory", "factory.makeBackend()", "Backend *"),
+ ("Factory factory", "factory.makeBackend()", "Backend *"),
+])
+def test_req_qml018_ac03_single_pointer_reference_and_cv_shapes_remain_inspectable(tmp_path, root, expression, returned):
+ """Supported root/value and API forms retain lexical CV rather than stripping it."""
+ native = NATIVE.replace("Backend *makeBackend()", returned + " makeBackend()")
+ inputs = sources(tmp_path, expression, native=native)
+ inputs["access.cpp"] = inputs["access.cpp"].replace("Factory *factory", root)
+ result = analysis(tmp_path, inputs)
+ assert len(sites(result, "context_binding")) == 1
+ member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend")
+ assert qt_metadata(member)["api_type_spelling"].strip() == returned.strip()
+ exposure = sites(result, "context_exposure")[0]
+ assert qt_metadata(exposure)["provider_root_type_spelling"].strip() == root.replace("factory", "").strip()
+
+
+@pytest.mark.parametrize("corruption", ["factory", "property", "getter", "root", "operator"])
+def test_req_qml018_ac06_consumer_rejects_corrupt_declarator_shape(tmp_path, corruption):
+ """Persisted canonical targets cannot lend API authority after shape corruption."""
+ result = analysis(tmp_path, sources(tmp_path))
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ by_id = {node["id"]: node for node in nodes}
+ edge = next(edge for edge in edges if edge.get("context") == "qt_context_subscription")
+ source, target = by_id[edge["source"]], by_id[edge["target"]]
+ if corruption in {"root", "operator"}:
+ chosen = next(node for node in nodes if qt_metadata(node).get("kind") == "context_exposure")
+ if corruption == "root":
+ update_qt(chosen, provider_root_type_spelling="Factory **")
+ else:
+ steps = qt_metadata(chosen)["provider_expression_steps"]
+ steps[0]["operator"] = "."
+ update_qt(chosen, provider_expression_steps=steps)
+ else:
+ kind, name = {"factory": ("member", "makeBackend"), "property": ("property", "service"),
+ "getter": ("member", "service")}[corruption]
+ chosen = next(node for node in nodes if qt_metadata(node).get("kind") == kind
+ and qt_metadata(node).get("raw_name") == name)
+ update_qt(chosen, api_type_spelling="Backend **" if corruption == "factory" else "Service *&")
+ assert not allows_qt_qml_edge(source, target, edge, source_id=source["id"], target_id=target["id"], nodes=by_id)
+
+
+@pytest.mark.parametrize("mode", ["address", "local", "this", "reference_field"])
+def test_req_qml018_ac03_source_owned_object_root_forms_retain_api(tmp_path, mode):
+ """Address-of, initialized locals, this and reference fields retain bounded lexical authority."""
+ inputs = sources(tmp_path, "&backend" if mode == "address" else "local->makeBackend()" if mode == "local"
+ else "factory->backend" if mode == "reference_field" else "factory->makeBackend()")
+ if mode == "address":
+ inputs["access.cpp"] = inputs["access.cpp"].replace("Backend *backend", "Backend backend")
+ elif mode == "local":
+ inputs["access.cpp"] = inputs["access.cpp"].replace(" QQmlApplicationEngine", " Factory *local = factory; QQmlApplicationEngine")
+ elif mode == "reference_field":
+ inputs["native.h"] = inputs["native.h"].replace("Backend *backend", "Backend &backend")
+ else:
+ body = 'void expose() { QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("backend", this);'
+ body += 'engine.load(QUrl("' + (tmp_path / "Main.qml").as_uri() + '")); }'
+ inputs["native.h"] = inputs["native.h"].replace("public: Service *service()", "public: " + body + " Service *service()")
+ inputs["access.cpp"] = '#include "native.h"\n'
+ result = analysis(tmp_path, inputs)
+ assert len(sites(result, "context_binding")) == 1
+ assert len(sites(result, "context_subscription")) == 2
+
+
+def test_req_qml018_ac03_opaque_root_alias_retains_shape_uncertainty(tmp_path):
+ """A pointer typedef is not normalized into a convenient one-object root."""
+ inputs = sources(tmp_path)
+ inputs["access.cpp"] = inputs["access.cpp"].replace("void use(Factory *factory", "using Handle = Factory**;\nvoid use(Handle factory")
+ result = analysis(tmp_path, inputs)
+ assert not sites(result, "context_binding")
+ md = qt_metadata(sites(result, "context_exposure")[0])
+ assert md["provider_root_type_spelling"] == "Handle"
+ assert md["provider_type_reason"] == "native_type_alias_unsupported"
+
+
+def test_req_qml018_ac03_reference_return_uses_canonical_generic_admission(tmp_path):
+ """An admitted reference factory supplies its exact generic callable and declared API."""
+ inputs = sources(tmp_path, native=NATIVE.replace("Backend *makeBackend()", "Backend &makeBackend()"))
+ result = analysis(tmp_path, inputs)
+ member = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend")
+ md = qt_metadata(member)
+ assert md["api_type_spelling"] == "Backend &" and md["api_type_status"] == "resolved"
+ assert md["generic_target_id"] in {node["id"] for node in result["nodes"]}
+ assert len(sites(result, "context_binding")) == 1
+
+
+@pytest.mark.parametrize("returned,status", [("const Backend *", "resolved"), ("Backend **", "unsupported")])
+def test_req_qml018_ac03_direct_producer_keeps_original_cv_shape_and_byte_position(tmp_path, returned, status):
+ """Direct declaration facts match the facade over BOM, CRLF and a Unicode prefix."""
+ from graphify.extractors.qt_cpp_exposure import _class_facts
+ from graphify.extractors.qt_cpp_facts import QtFacts
+ from graphify.extractors.qt_cpp_mapping import map_cpp
+ from graphify.extractors.qt_cpp_syntax import read_cpp
+ native = "\ufeff// café\r\n" + NATIVE.replace("Backend *makeBackend()", returned + "makeBackend()").replace("\n", "\r\n")
+ result = analysis(tmp_path, sources(tmp_path, native=native))
+ unit = read_cpp(tmp_path / "native.h", tmp_path)
+ mapping = map_cpp(unit, result["nodes"], result["edges"], root=tmp_path)
+ facts = QtFacts(unit)
+ _class_facts(mapping, facts, classes=mapping.classes)
+ direct = next(node for node in facts.nodes if qt_metadata(node).get("kind") == "member"
+ and qt_metadata(node).get("raw_name") == "makeBackend")
+ facade = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "makeBackend")
+ fields = ("api_type_spelling", "api_type_target_id", "api_type_status", "api_type_reason", "api_type_position")
+ direct_md = qt_metadata(direct)
+ assert {key: direct_md[key] for key in fields} == {key: qt_metadata(facade)[key] for key in fields}
+ assert direct_md["api_type_spelling"] == returned and direct_md["api_type_status"] == status
+ assert unit.source[direct_md["api_type_position"]:].startswith(returned.encode())
diff --git a/tests/test_qt_declared_proof_integrity.py b/tests/test_qt_declared_proof_integrity.py
new file mode 100644
index 0000000000..4d9ffc627f
--- /dev/null
+++ b/tests/test_qt_declared_proof_integrity.py
@@ -0,0 +1,100 @@
+"""REQ-QML-018-AC06: borrowed typed declarations retain their source authority."""
+from __future__ import annotations
+
+import copy
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.qt_qml_projection import allows_qt_qml_edge
+from tests.qt_adoption_fixture import NATIVE, QML, sources
+from tests.qt_analysis_helpers import analysis
+
+
+def serialized_subscription(result, *, notify=False):
+ """Copy accepted producer output before altering one consumer-owned proof input."""
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ by_id = {node["id"]: node for node in nodes}
+ edge = next(item for item in edges if item.get("context") == "qt_context_subscription"
+ and bool(qt_metadata(item)["endpoint_proof"].get("notify_property_id")) is notify)
+ source, target = by_id[edge["source"]], by_id[edge["target"]]
+ def accepted():
+ return allows_qt_qml_edge(source, target, edge, source_id=source["id"], target_id=target["id"], nodes=by_id)
+ assert accepted()
+ return nodes, by_id, edge, accepted
+
+
+@pytest.mark.parametrize("corruption", ["getter_arity", "accessor_owner", "accessor_file", "accessor_span",
+ "accessor_name", "factory_callable", "factory_class", "factory_owner", "property_owner",
+ "factory_file", "factory_span", "property_file", "property_span", "getter_callable"])
+def test_req_qml018_ac06_typed_hop_rejects_altered_authoritative_declaration(tmp_path, corruption):
+ """Canonical type IDs do not repair changed source ownership, callable roles or READ arity."""
+ nodes, by_id, _, accepted = serialized_subscription(analysis(tmp_path, sources(tmp_path)))
+ def chosen(kind, name):
+ return next(node for node in nodes if qt_metadata(node).get("kind") == kind
+ and qt_metadata(node).get("raw_name") == name)
+ prop, getter, factory = chosen("property", "service"), chosen("member", "service"), chosen("member", "makeBackend")
+ accessor = next(node for node in nodes if qt_metadata(node).get("kind") == "property_accessor"
+ and qt_metadata(node).get("property_id") == prop["id"] and qt_metadata(node).get("accessor_role") == "read")
+ target = accessor if corruption.startswith("accessor") else prop if corruption.startswith("property") else getter if corruption.startswith("getter") else factory
+ if corruption == "getter_arity":
+ update_qt(target, parameter_types=["int"])
+ elif corruption.endswith("callable") or corruption == "factory_class":
+ by_id[qt_metadata(target)["generic_target_id"]]["_callable_class" if corruption == "factory_class" else "_callable"] = corruption == "factory_class"
+ elif corruption.endswith("owner"):
+ update_qt(target, owner_id="missing")
+ elif corruption.endswith("file"):
+ target["source_file"] = "Main.qml"
+ elif corruption.endswith("name"):
+ update_qt(target, raw_name="unrelated")
+ else:
+ span = qt_metadata(target)["span"]
+ span["start_byte"] += 1_000_000
+ span["end_byte"] += 1_000_000
+ update_qt(target, span=span)
+ assert not accepted()
+
+
+@pytest.mark.parametrize("corruption", ["owner", "file", "span", "name", "property_owner"])
+def test_req_qml018_ac06_typed_notify_revalidates_property_accessor_provenance(tmp_path, corruption):
+ """A property-change subscription retains the same owner/name/file/span guard as lookup."""
+ native = NATIVE.replace("Q_PROPERTY(int count READ count)", "Q_PROPERTY(int count READ count NOTIFY actualCount)")
+ native = native.replace("signals: void ready();", "signals: void ready(); void actualCount();")
+ result = analysis(tmp_path, sources(tmp_path, native=native, qml=QML.replace("function onReady()", "function onCountChanged()")))
+ nodes, by_id, edge, accepted = serialized_subscription(result, notify=True)
+ prop = by_id[qt_metadata(edge)["endpoint_proof"]["notify_property_id"]]
+ accessor = next(node for node in nodes if qt_metadata(node).get("kind") == "property_accessor"
+ and qt_metadata(node).get("property_id") == prop["id"] and qt_metadata(node).get("accessor_role") == "notify")
+ if corruption in {"owner", "property_owner"}:
+ update_qt(prop if corruption == "property_owner" else accessor, owner_id="missing")
+ elif corruption == "file":
+ accessor["source_file"] = "Main.qml"
+ elif corruption == "name":
+ update_qt(accessor, raw_name="unrelated")
+ else:
+ span = qt_metadata(accessor)["span"]
+ span["start_byte"] += 1_000_000
+ span["end_byte"] += 1_000_000
+ update_qt(accessor, span=span)
+ assert not accepted()
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml018_ac04_ac06_reloaded_notify_proof_retains_mapping_identity(tmp_path, directed):
+ """Persisted NetworkX mappings lend the same accepted NOTIFY authority as raw producer nodes."""
+ import json
+ from graphify.build import build_from_json
+ from graphify.export import to_json
+ from graphify.paths import load_node_link_graph
+ native = NATIVE.replace("Q_PROPERTY(int count READ count)", "Q_PROPERTY(int count READ count NOTIFY actualCount)")
+ native = native.replace("signals: void ready();", "signals: void ready(); void actualCount();")
+ result = analysis(tmp_path, sources(tmp_path, native=native, qml=QML.replace("function onReady()", "function onCountChanged()")))
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path), force=True)
+ restored = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ for edge in result["edges"]:
+ if edge.get("context") == "qt_context_subscription" and qt_metadata(edge)["endpoint_proof"].get("notify_property_id"):
+ source, target = edge["source"], edge["target"]
+ assert allows_qt_qml_edge(restored.nodes[source], restored.nodes[target], restored.edges[source, target],
+ source_id=source, target_id=target, nodes=restored.nodes)
diff --git a/tests/test_qt_engine_identity.py b/tests/test_qt_engine_identity.py
new file mode 100644
index 0000000000..bca07c94ad
--- /dev/null
+++ b/tests/test_qt_engine_identity.py
@@ -0,0 +1,229 @@
+"""REQ-QML-017-AC03/AC04: declaration identity prevents cross-engine injection."""
+from __future__ import annotations
+
+import json
+import copy
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_identity import CppDeclarationIdentity, source_reference_key
+from graphify.extractors.qt_cpp_mapping import map_cpp
+from graphify.extractors.qt_cpp_syntax import read_cpp
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+
+BACKEND = '''class Backend : public QObject {
+ Q_OBJECT Q_PROPERTY(int count READ count)
+public: int count() { return 1; }
+};
+'''
+
+
+def persisted(root, source, qml=None):
+ """Analyze synthetic sources and inspect completed production JSON output."""
+ result = analysis(root, {"access.cpp": BACKEND + source,
+ "Main.qml": qml or 'import QtQml\nQtObject { property int value: backend.count }'})
+ graph = build_from_json(result, root=root)
+ output = root / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ return result, load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+
+
+@pytest.mark.parametrize("exposure,qml", [
+ ('engine.rootContext()->setContextProperty("backend", backend);', 'property int value: backend.count'),
+ ('engine.rootContext()->setContextObject(backend);', 'property int value: count'),
+ ('engine.setInitialProperties({{"backend", backend}});', 'property var backend; property int value: backend.count'),
+])
+def test_req_qml017_ac03_disjoint_same_named_engines_cannot_share_provider(tmp_path, exposure, qml):
+ """An engine destroyed in one block cannot supply a later distinct engine."""
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(Backend *backend) {{
+ {{ QQmlApplicationEngine engine; {exposure} }}
+ {{ QQmlApplicationEngine engine; engine.load(QUrl("{url}")); }}
+}}'''
+ result, graph = persisted(tmp_path, source, 'import QtQml\nQtObject { ' + qml + ' }')
+ assert not sites(result, "context_binding")
+ assert not sites(result, "context_access")
+ assert not [edge for _, _, edge in graph.edges(data=True)
+ if edge.get("context") in {"qt_context_member", "qt_context_exposure", "qt_initial_property"}]
+
+
+def test_req_qml017_ac03_same_engine_positive_retains_persisted_provider(tmp_path):
+ """One declaration may expose a provider and later load its own component."""
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(Backend *backend) {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("backend", backend);
+ engine.load(QUrl("{url}")); }}'''
+ result, graph = persisted(tmp_path, source)
+ binding = sites(result, "context_binding")[0]
+ access = sites(result, "context_access")[0]
+ assert qt_metadata(binding)["status"] == "resolved"
+ assert graph.has_edge(binding["id"], qt_metadata(binding)["component_target_id"])
+ assert graph.has_edge(access["id"], qt_metadata(access)["target_id"])
+
+
+def test_req_qml017_ac03_nested_engine_shadow_does_not_receive_outer_provider(tmp_path):
+ """A shadow engine loads its document without inheriting an outer exposure."""
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(Backend *backend) {{ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("backend", backend);
+ {{ QQmlApplicationEngine engine; engine.load(QUrl("{url}")); }} }}'''
+ result, graph = persisted(tmp_path, source)
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+ assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_context_member"]
+
+
+def test_req_qml017_ac03_local_provider_lifetime_cannot_supply_later_load(tmp_path):
+ """Even one engine cannot extend a source-local provider beyond its block."""
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use() {{ QQmlApplicationEngine engine;
+ {{ Backend backend; engine.rootContext()->setContextProperty("backend", &backend); }}
+ engine.load(QUrl("{url}")); }}'''
+ result, graph = persisted(tmp_path, source)
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+ assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_context_member"]
+
+
+def test_req_qml017_ac03_engine_and_provider_parameters_have_exact_identity(tmp_path):
+ """Direct parameters establish one source relationship without caller guesses."""
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(QQmlApplicationEngine &engine, Backend *backend) {{
+ engine.rootContext()->setContextProperty("backend", backend); engine.load(QUrl("{url}")); }}'''
+ result, graph = persisted(tmp_path, source)
+ binding = sites(result, "context_binding")[0]
+ metadata = qt_metadata(binding)
+ assert metadata["status"] == "resolved"
+ assert len(metadata["engine_declaration_id"]) == len(metadata["provider_declaration_id"]) == 64
+ assert graph.has_edge(binding["id"], metadata["component_target_id"])
+
+
+@pytest.mark.parametrize("prefix,engine,role,status,reason", [
+ ('QQmlApplicationEngine *engine = new QQmlApplicationEngine; engine = replacement;', 'engine->', "engine", "dynamic", "reassigned_declaration"),
+ ('QQmlApplicationEngine engine; QQmlApplicationEngine engine;', 'engine.', "engine", "ambiguous", "declaration_ambiguous"),
+ ('QQmlApplicationEngine engine; backend = other;', 'engine.', "provider", "dynamic", "reassigned_declaration"),
+])
+def test_req_qml017_ac03_rejected_identity_preserves_precise_source_diagnostic(tmp_path, prefix, engine, role, status, reason):
+ """Production rejections keep lexical reasons and publish no provider edges."""
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(Backend *backend, Backend *other, QQmlApplicationEngine *replacement) {{
+ {prefix} {engine}rootContext()->setContextProperty("backend", backend);
+ {engine}load(QUrl("{url}")); }}'''
+ result, graph = persisted(tmp_path, source)
+ exposure = qt_metadata(sites(result, "context_exposure")[0])
+ assert exposure[role + "_identity_status"] == status
+ assert exposure[role + "_identity_reason"] == reason
+ assert (exposure["status"], exposure["reason"]) == (status, reason)
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+ assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_context_member"]
+
+
+@pytest.mark.parametrize("second,expected", [
+ ("backend", ("dynamic", "reassigned_declaration")),
+ ("missing", ("unavailable", "provider_component_scope_unestablished")),
+])
+def test_req_qml017_ac03_initial_properties_report_only_shared_identity_failure(tmp_path, second, expected):
+ """A mixed provider list cannot misreport one item's reason as the whole list."""
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(Backend *backend, Backend *other) {{ QQmlApplicationEngine engine;
+ backend = other; engine.setInitialProperties({{{{"backend", backend}}, {{"extra", {second}}}}});
+ engine.load(QUrl("{url}")); }}'''
+ result, graph = persisted(tmp_path, source, 'import QtQml\nQtObject { property var backend; property var extra; property int value: backend.count }')
+ metadata = qt_metadata(sites(result, "initial_properties")[0])
+ assert (metadata["status"], metadata["reason"]) == expected
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+ assert not [edge for _, _, edge in graph.edges(data=True) if edge.get("context") == "qt_initial_property"]
+
+
+def identity_index(root, source):
+ """The shared helper consumes real accepted syntax and canonical C++ mapping."""
+ result = analysis(root, {"identity.cpp": source})
+ unit = read_cpp(root / "identity.cpp", root)
+ snapshot = copy.deepcopy((result["nodes"], result["edges"]))
+ mapping = map_cpp(unit, result["nodes"], result["edges"], root=root)
+ return CppDeclarationIdentity(unit, mapping), unit, result, snapshot
+
+
+def test_req_qml017_ac03_nested_shadow_and_disjoint_declarations_are_distinct(tmp_path):
+ """Nearest declaration wins only within its own source lexical lifetime."""
+ source = '''void use(QQmlApplicationEngine &engine) {
+ engine.load("outer");
+ { QQmlApplicationEngine engine; engine.load("inner"); }
+ { QQmlApplicationEngine engine; engine.load("other"); }
+ engine.load("again");
+}'''
+ index, unit, result, before = identity_index(tmp_path, source)
+ values = [index.resolve("engine", unit.source.index(('engine.load("' + name).encode()))
+ for name in ("outer", "inner", "other", "again")]
+ assert all(value["status"] == "resolved" for value in values)
+ assert values[0]["declaration_id"] == values[3]["declaration_id"]
+ assert len({value["declaration_id"] for value in values}) == 3
+ assert (result["nodes"], result["edges"]) == before
+
+
+def test_req_qml017_ac04_auto_and_explicit_handles_keep_declaration_identity(tmp_path):
+ """An initializer call and subsequent handle use share the actual declaration."""
+ source = '''void use() { QQmlApplicationEngine engine;
+ auto root = engine.rootObjects().first(); root->property("value");
+ QObject *other = engine.rootObjects().first(); other->property("value"); }'''
+ index, unit, _, _ = identity_index(tmp_path, source)
+ for name in ("root", "other"):
+ assigned = unit.source.index(b"engine.rootObjects", unit.source.index((name + " =").encode()))
+ used = unit.source.index((name + "->property").encode())
+ left, right = index.resolve(name, assigned), index.resolve(name, used)
+ assert left["status"] == right["status"] == "resolved"
+ assert left["declaration_id"] == right["declaration_id"]
+
+
+@pytest.mark.parametrize("prefix,expected", [
+ ('engine = replacement;', "dynamic"),
+ ('{ QQmlApplicationEngine *engine = replacement; engine = replacement; }', "resolved"),
+ ('QQmlApplicationEngine *engine;', "ambiguous"),
+])
+def test_req_qml017_ac04_writes_and_duplicate_declarations_fail_closed(tmp_path, prefix, expected):
+ """An inner write cannot poison the outer declaration; actual writes reject."""
+ source = f'void use(QQmlApplicationEngine *replacement) {{ QQmlApplicationEngine *engine = new QQmlApplicationEngine; {prefix} engine->load("main"); }}'
+ index, unit, _, _ = identity_index(tmp_path, source)
+ value = index.resolve("engine", unit.source.index(b'engine->load("main")'))
+ assert value["status"] == expected
+ assert bool(value["declaration_id"]) is (expected == "resolved")
+
+
+@pytest.mark.parametrize("source", [
+ 'void use() { QQmlApplicationEngine engine; if (enabled) { engine.load("main"); } }',
+ 'void use() { for (QQmlApplicationEngine engine; enabled;) { engine.load("main"); } }',
+ 'void use() { QQmlApplicationEngine engine; auto f = [&engine] { engine.load("main"); }; }',
+ 'void use() {\n#if ENABLED\n QQmlApplicationEngine engine;\n#endif\n engine.load("main"); }',
+])
+def test_req_qml017_ac04_conditional_and_deferred_lifetimes_have_no_identity(tmp_path, source):
+ """Conditions and deferred captures cannot authorize a definite engine join."""
+ index, unit, _, _ = identity_index(tmp_path, source)
+ value = index.resolve("engine", unit.source.index(b'engine.load("main")'))
+ assert value["status"] == "dynamic" and value["declaration_id"] == ""
+
+
+def test_req_qml017_ac04_ids_are_portable_with_original_unicode_bom_crlf_spans(tmp_path):
+ """IDs contain no checkout root, and span slices retain original byte offsets."""
+ source = '\ufeff// café ✓\r\nvoid use(QQmlApplicationEngine &engine) {\r\n engine.load("main"); }'
+ ids = []
+ for folder in (tmp_path / "one", tmp_path / "two"):
+ folder.mkdir()
+ index, unit, _, _ = identity_index(folder, source)
+ value = index.resolve("engine", unit.source.index(b'engine.load("main")'))
+ assert value["status"] == "resolved" and len(value["declaration_id"]) == 64
+ span = value["declaration_span"]
+ assert unit.source[span["start_byte"]:span["end_byte"]] == b"&engine"
+ assert span["start_row"] == 1
+ ids.append(value["declaration_id"])
+ assert ids[0] == ids[1]
+
+
+@pytest.mark.parametrize("identity", [None, "", "a" * 63, "A" * 64, ["a" * 64]])
+def test_req_qml017_ac04_missing_or_corrupted_transport_cannot_fall_back_to_name(identity):
+ """Legacy names and malformed IDs remain unusable even with an owner key."""
+ metadata = {"owner_scope_key": "source_owner", "receiver_reference": "engine", "receiver_declaration_id": identity}
+ before = copy.deepcopy(metadata)
+ assert source_reference_key(metadata) is None
+ assert metadata == before
diff --git a/tests/test_qt_engine_identity_updates.py b/tests/test_qt_engine_identity_updates.py
new file mode 100644
index 0000000000..ae3a5fd1fe
--- /dev/null
+++ b/tests/test_qt_engine_identity_updates.py
@@ -0,0 +1,189 @@
+"""REQ-QML-017-AC04: engine identity through real refresh, failure and consumers."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.affected import affected_nodes, load_graph
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_consumer_helpers import cli as consumer_cli
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+HEADER = '''class Backend : public QObject { Q_OBJECT Q_PROPERTY(int count READ count)
+public: int count() { return 1; } };
+class OtherBackend : public QObject { Q_OBJECT Q_PROPERTY(int count READ count)
+public: int count() { return 2; } };
+'''
+
+
+def loader(state="same", target="Main.qml"):
+ """Only lexical source placement changes; URLs refer to accepted resources."""
+ expose = 'engine.rootContext()->setContextProperty("backend", backend);'
+ load = f'engine.load(QUrl("qrc:/ui/{target}"));'
+ if state == "disjoint":
+ body = f'{{ QQmlApplicationEngine engine; {expose} }} {{ QQmlApplicationEngine engine; {load} }}'
+ elif state == "nested":
+ body = ('QQmlApplicationEngine engine; ' + expose +
+ ' { QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("backend", other); '
+ 'engine.load(QUrl("qrc:/ui/Main.qml")); } engine.load(QUrl("qrc:/ui/Alt.qml"));')
+ else:
+ body = 'QQmlApplicationEngine engine; ' + (expose if state != "removed" else "") + load
+ return '#include "backend.h"\nvoid use(Backend *backend, OtherBackend *other) { ' + body + ' }\n'
+
+
+def fixture(root, state="same"):
+ sources = {
+ "backend.h": HEADER,
+ "loader.cpp": loader(state),
+ "Main.qml": 'import QtQml\nQtObject { property int observed: backend.count }\n',
+ "Alt.qml": 'import QtQml\nQtObject { property int alternative: backend.count }\n',
+ "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml Alt.qml SOURCES backend.h loader.cpp)\n',
+ "ui.qrc": 'Main.qmlAlt.qml',
+ "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n',
+ }
+ for name, source in sources.items():
+ (root / name).write_bytes(source.encode())
+ return root / "loader.cpp"
+
+
+def facts(graph, kind):
+ return {identity: qt_metadata(node) for identity, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == kind}
+
+
+def assert_binding(graph, filename, provider="Backend"):
+ """A resolved provider must retain its exact engine and class evidence."""
+ bindings = [md for md in facts(graph, "context_binding").values()
+ if graph.nodes[md["component_target_id"]]["source_file"] == filename]
+ assert len(bindings) == 1 and bindings[0]["status"] == "resolved"
+ assert bindings[0]["provider_class_name"] == provider
+ assert len(bindings[0]["engine_declaration_id"]) == len(bindings[0]["provider_declaration_id"]) == 64
+ accesses = [(identity, md) for identity, md in facts(graph, "context_access").items()
+ if graph.nodes[identity]["source_file"] == filename]
+ assert len(accesses) == 1
+ assert graph.has_edge(accesses[0][0], accesses[0][1]["target_id"])
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_engine_scope_changes_remove_stale_provider_links(tmp_path, monkeypatch, operation):
+ """Cold/warm and real manual/watch agree across scope, provider and load edits."""
+ source = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold-cache")
+ assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ assert_binding(initial, "Main.qml")
+ retired = set(facts(initial, "context_access")) | set(facts(initial, "context_binding"))
+ kept = unrelated(initial)
+
+ source.write_bytes(loader("disjoint").encode())
+ rejected = run(tmp_path, monkeypatch, operation, [source])
+ assert not facts(rejected, "context_binding") and not facts(rejected, "context_access")
+ assert not retired.intersection(rejected)
+ assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".disjoint-cache"))
+
+ # Nested shadow engines retain their own exact provider and document;
+ # neither the spelling nor source ordering may select the other provider.
+ source.write_bytes(loader("nested").encode())
+ nested = run(tmp_path, monkeypatch, operation, [source])
+ assert_binding(nested, "Main.qml", "OtherBackend")
+ assert_binding(nested, "Alt.qml", "Backend")
+ assert normalized(nested) == normalized(clean(tmp_path, tmp_path / ".nested-cache"))
+
+ source.write_bytes(loader("removed").encode())
+ removed = run(tmp_path, monkeypatch, operation, [source])
+ assert not facts(removed, "context_binding") and not facts(removed, "context_access")
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ source.write_bytes(loader("same", "Alt.qml").encode())
+ moved = run(tmp_path, monkeypatch, operation, [source])
+ assert_binding(moved, "Alt.qml")
+ assert not [identity for identity in facts(moved, "context_access")
+ if moved.nodes[identity]["source_file"] == "Main.qml"]
+ assert normalized(moved) == normalized(clean(tmp_path, tmp_path / ".moved-cache"))
+
+ # Resource-only changes must invalidate the unchanged C++ loader/provider,
+ # without preserving the previous component binding from graph context.
+ resource = tmp_path / "ui.qrc"
+ valid_resource = resource.read_bytes()
+ resource.write_bytes(valid_resource.replace(b'alias="Alt.qml"', b'alias="changed.qml"'))
+ missing = run(tmp_path, monkeypatch, operation, [resource])
+ assert not facts(missing, "context_binding") and not facts(missing, "context_access")
+ assert normalized(missing) == normalized(clean(tmp_path, tmp_path / ".metadata-cache"))
+ resource.write_bytes(valid_resource)
+ assert normalized(run(tmp_path, monkeypatch, operation, [resource])) == normalized(moved)
+
+ # An unchanged C++ exposure cannot override a newly declared QML property.
+ qml = tmp_path / "Alt.qml"
+ qml.write_bytes(qml.read_bytes().replace(b"property int alternative", b"property var backend; property int alternative"))
+ shadowed = run(tmp_path, monkeypatch, operation, [qml])
+ assert len(facts(shadowed, "context_binding")) == 1 and not facts(shadowed, "context_access")
+ assert normalized(shadowed) == normalized(clean(tmp_path, tmp_path / ".qml-shadow-cache"))
+ assert unrelated(shadowed) == kept
+ before = (tmp_path / "graphify-out/graph.json").read_bytes()
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(shadowed)
+ assert (tmp_path / "graphify-out/graph.json").read_bytes() == before
+
+
+@pytest.mark.parametrize("operation", ["manual", "force", "watch"])
+def test_req_qml017_ac04_malformed_identity_source_retains_products_and_retries(tmp_path, monkeypatch, operation):
+ """Actual parser rejection preserves all durable products, including forced CLI."""
+ source = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ mode = "manual" if operation == "force" else operation
+ initial = run(tmp_path, monkeypatch, mode)
+ assert_binding(initial, "Main.qml")
+ output = tmp_path / "graphify-out"
+ products = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+ before = {name: (output / name).read_bytes() for name in products}
+ valid = source.read_bytes()
+ source.write_bytes(valid + b"\nvoid broken({")
+ if operation == "watch":
+ from graphify.watch import _rebuild_code
+ assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ else:
+ with pytest.raises(SystemExit) as rejected:
+ if operation == "force":
+ from tests.test_qt_cpp_upgrade_invalidation import cli
+ cli(tmp_path, monkeypatch, "update", force=True)
+ else:
+ run(tmp_path, monkeypatch, mode, [source])
+ assert rejected.value.code == 1
+ assert before == {name: (output / name).read_bytes() for name in products}
+ source.write_bytes(valid)
+ repaired = run(tmp_path, monkeypatch, mode, [source])
+ assert normalized(repaired) == normalized(initial)
+ assert_binding(repaired, "Main.qml")
+ assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(initial)
+
+
+def test_req_qml017_ac04_directed_query_affected_and_reload_keep_scoped_provider(tmp_path, monkeypatch, capsys):
+ """The real directed consumer graph distinguishes the two nested providers."""
+ fixture(tmp_path, "nested")
+ paths = [tmp_path / name for name in ("backend.h", "loader.cpp", "Main.qml", "Alt.qml", "CMakeLists.txt", "ui.qrc", "keep.py")]
+ from graphify.extract import extract
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ path = tmp_path / "directed.json"
+ assert to_json(graph, {}, str(path), force=True)
+ loaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ assert_binding(loaded, "Main.qml", "OtherBackend")
+ assert_binding(loaded, "Alt.qml", "Backend")
+ access, metadata = next((identity, md) for identity, md in facts(loaded, "context_access").items()
+ if loaded.nodes[identity]["source_file"] == "Main.qml")
+ assert loaded.has_edge(access, metadata["target_id"])
+ assert not loaded.has_edge(metadata["target_id"], access)
+ text = consumer_cli(monkeypatch, capsys, path, "query", access)
+ assert "Qt context access: backend.count" in text and "Main.qml" in text
+ assert "Main.qml" in consumer_cli(monkeypatch, capsys, path, "explain", access)
+ hits = {hit.node_id for hit in affected_nodes(load_graph(path), metadata["target_id"], relations=["uses"], depth=1)}
+ assert access in hits
+ assert not {identity for identity in facts(loaded, "context_access")
+ if loaded.nodes[identity]["source_file"] == "Alt.qml"}.intersection(hits)
diff --git a/tests/test_qt_event_incremental_consumers.py b/tests/test_qt_event_incremental_consumers.py
new file mode 100644
index 0000000000..77993f8ba7
--- /dev/null
+++ b/tests/test_qt_event_incremental_consumers.py
@@ -0,0 +1,143 @@
+"""QML-016-AC04 event edit/removal parity through real update/watch persistence."""
+from __future__ import annotations
+
+from collections import Counter
+
+import pytest
+
+import graphify.__main__ as entrypoint
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.watch import _rebuild_code
+from tests.test_qt_final_incremental_parity import clean, normalized, published
+from tests.test_qt_signals_slots import SOURCE
+
+
+NATIVE = SOURCE.replace(
+ "private slots:", "public slots:\n void active(int value) {}\nprivate slots:"
+).replace("\n}\n", "\n receiver->active(5);\n}\n")
+UNCHANGED = '''class Heartbeat : public QObject { Q_OBJECT
+signals: void beat();
+public: void tick() { emit beat(); }
+};
+'''
+EVENT_KINDS = {"emission", "connect", "disconnect"}
+
+
+def project(root):
+ sources = {
+ "events.cpp": NATIVE,
+ "unchanged.cpp": UNCHANGED,
+ # Keep ordinary unrelated declarations and a real call dependency while
+ # removing native events; a tiny-graph shrink guard is not the subject.
+ "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n"
+ + "\n".join(f"def keep_{index}(): return {index}" for index in range(20)),
+ }
+ for name, text in sources.items():
+ (root / name).write_text(text, encoding="utf-8", newline="")
+ return root / "events.cpp"
+
+
+def run(root, monkeypatch, operation, changes=None):
+ if operation == "manual":
+ # Production opt-out isolates installed-skill maintenance, without
+ # replacing any CLI, update, parser, resolver or persistence function.
+ monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", "1")
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", str(root), "--no-cluster"])
+ entrypoint.main()
+ else:
+ assert _rebuild_code(root, changed_paths=changes, no_cluster=True)
+ return published(root)
+
+
+def source_facts(graph, files):
+ """Retain every public field and logical typed edge, including source spans."""
+ nodes, edges = normalized(graph)
+ ids = {identity for identity, data in graph.nodes(data=True) if data.get("source_file") in files}
+ return ({identity: nodes[identity] for identity in ids},
+ Counter({edge: count for edge, count in edges.items() if edge[0] in ids and edge[1] in ids}))
+
+
+def event_sites(graph, file="events.cpp"):
+ return {identity: metadata for identity, data in graph.nodes(data=True)
+ if data.get("source_file") == file and (metadata := qt_metadata(data)).get("kind") in EVENT_KINDS}
+
+
+def assert_distinct_mechanisms(graph, expected):
+ sites = event_sites(graph)
+ assert Counter(metadata["kind"] for metadata in sites.values()) == Counter(expected)
+ assert all(metadata["status"] == "resolved" for metadata in sites.values())
+ assert all(metadata["runtime_delivery"] == "unverified" for metadata in sites.values()
+ if metadata["kind"] in {"connect", "disconnect"})
+ # Event declarations reference source endpoints. They never manufacture a
+ # signal-to-receiver call or replace the direct public-slot invocation.
+ assert not any(data.get("_src", source) in sites and data.get("relation") == "calls"
+ for source, _, data in graph.edges(data=True))
+ members = [metadata for _, data in graph.nodes(data=True)
+ if data.get("source_file") == "events.cpp" and (metadata := qt_metadata(data)).get("kind") == "member"
+ and metadata.get("raw_name") == "active"]
+ assert len(members) == 1 and members[0]["roles"] == ["slot"]
+ direct_target = members[0]["generic_target_id"]
+ # Ordinary untyped calls in a default undirected graph have no serialized
+ # logical-direction promise. Assert their actual canonical endpoint pair.
+ direct = [(target if source == direct_target else source, data) for source, target, data in graph.edges(data=True)
+ if direct_target in {source, target} and data.get("relation") == "calls"]
+ assert len(direct) == 1 and direct[0][0] not in sites
+ assert graph.nodes[direct[0][0]]["label"] == "wire()"
+ assert direct[0][1]["source_file"] == "events.cpp"
+ return sites
+
+
+def compare_rebuild(graph, root, cache):
+ cold = clean(root, cache)
+ warm = clean(root, cache)
+ assert normalized(graph) == normalized(cold) == normalized(warm)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_qml016_ac04_event_mutation_and_removal_match_clean_rebuild_without_delivery_calls(
+ tmp_path, monkeypatch, operation
+):
+ """Success: changed source facts; state transition: no stale event endpoints.
+
+ Acceptance covers complete cold/warm parity and persistence, unchanged Qt
+ and Python source retention, and direct-slot/event relationship separation.
+ """
+ source = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ compare_rebuild(initial, tmp_path, tmp_path / ".initial-clean")
+ initial_sites = assert_distinct_mechanisms(initial, {"emission": 3, "connect": 3, "disconnect": 1})
+ untouched = source_facts(initial, {"unchanged.cpp", "keep.py"})
+ assert untouched[0] and untouched[1] and len(event_sites(initial, "unchanged.cpp")) == 1
+
+ edited_text = NATIVE.replace("emit changed(1);", "Q_EMIT changed(11);").replace(
+ "Q_EMIT changed(2);", "changed(12);"
+ ).replace("Qt::QueuedConnection", "Qt::DirectConnection")
+ source.write_text(edited_text, encoding="utf-8", newline="")
+ edited = run(tmp_path, monkeypatch, operation, [source])
+ compare_rebuild(edited, tmp_path, tmp_path / ".edited-clean")
+ edited_sites = assert_distinct_mechanisms(edited, {"emission": 3, "connect": 3, "disconnect": 1})
+ assert sum(metadata["explicit_emit"] for metadata in edited_sites.values() if metadata["kind"] == "emission") == 1
+ assert any(metadata.get("declared_type") == "DirectConnection" for metadata in edited_sites.values())
+ assert not any(metadata.get("declared_type") == "QueuedConnection" for metadata in edited_sites.values())
+ assert normalized(edited) != normalized(initial)
+ assert source_facts(edited, {"unchanged.cpp", "keep.py"}) == untouched
+ assert set(initial_sites) - set(edited_sites)
+ assert all(identity not in edited for identity in set(initial_sites) - set(edited_sites))
+
+ removed_text = edited_text.replace("Q_EMIT changed(11); changed(12); changed(3);", "")
+ removed_text = "\n".join(line for line in removed_text.splitlines()
+ if "QObject::connect(" not in line and "QObject::disconnect(" not in line) + "\n"
+ source.write_text(removed_text, encoding="utf-8", newline="")
+ removed = run(tmp_path, monkeypatch, operation, [source])
+ compare_rebuild(removed, tmp_path, tmp_path / ".removed-clean")
+ assert_distinct_mechanisms(removed, {})
+ assert all(identity not in removed for identity in edited_sites)
+ assert not any(data.get("source_file") == "events.cpp" and qt_metadata(data).get("kind") == "event_endpoint"
+ for _, data in removed.nodes(data=True))
+ assert not any(data.get("source_file") == "events.cpp" and data.get("context", "").startswith(
+ ("qt_signal_emit", "qt_connect_", "qt_disconnect_")
+ ) for _, _, data in removed.edges(data=True))
+ assert source_facts(removed, {"unchanged.cpp", "keep.py"}) == untouched
+ assert len(event_sites(removed, "unchanged.cpp")) == 1
diff --git a/tests/test_qt_events_boundaries.py b/tests/test_qt_events_boundaries.py
new file mode 100644
index 0000000000..bbfec1170a
--- /dev/null
+++ b/tests/test_qt_events_boundaries.py
@@ -0,0 +1,80 @@
+"""Native Qt event overload, callable, conditional and false-positive boundaries."""
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_signals_slots import SOURCE
+
+
+def test_functor_function_and_connection_handle_disconnect(tmp_path):
+ source = SOURCE + '''class Functor { public: void operator()(int value) {} };
+void consume(int value) {}
+void extra(Sender *sender, Receiver *receiver) {
+ Functor callable;
+ QObject::connect(sender, &Sender::changed, receiver, callable);
+ QObject::connect(sender, &Sender::changed, &consume);
+ auto handle = QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);
+ QObject::disconnect(handle);
+ QObject::disconnect(sender, nullptr, receiver, nullptr);
+}
+'''
+ result = analysis(tmp_path, {"events.cpp": source})
+ connections = sites(result, "connect")[-3:]
+ assert all(qt_metadata(site)["status"] == "resolved" for site in connections), [qt_metadata(site) for site in connections]
+ disconnects = sites(result, "disconnect")[-2:]
+ assert all(qt_metadata(site)["status"] == "resolved" for site in disconnects)
+ assert qt_metadata(disconnects[0])["target_id"] == connections[-1]["id"]
+ assert qt_metadata(disconnects[1])["reason"] == "source_wildcard_disconnect"
+
+
+def test_explicit_cast_signal_to_signal_and_condition_flags(tmp_path):
+ source = SOURCE + '''void extra(Sender *sender, Sender *other, bool enabled, Qt::ConnectionType mode) {
+ if (enabled) QObject::connect(sender, static_cast(&Sender::changed), other, &Sender::changed, Qt::DirectConnection | Qt::SingleShotConnection);
+ QObject::connect(sender, &Sender::changed, other, &Sender::changed, mode);
+}
+'''
+ result = analysis(tmp_path, {"events.cpp": source})
+ first, second = sites(result, "connect")[-2:]
+ assert qt_metadata(first)["status"] == "resolved" and qt_metadata(first)["conditional"]
+ assert qt_metadata(first)["signal_target_id"] == qt_metadata(first)["receiver_target_id"]
+ assert qt_metadata(first)["flags"] == ["SingleShotConnection"]
+ assert qt_metadata(second)["dynamic_flags"]
+ endpoints = [node for node in sites(result, "event_endpoint") if qt_metadata(node)["owner_id"] == first["id"]]
+ assert len(endpoints) == 2 and endpoints[0]["id"] != endpoints[1]["id"]
+
+
+def test_private_typed_pointer_and_incompatible_receiver_are_rejected(tmp_path):
+ source = SOURCE.replace("&Receiver::accept, Qt::QueuedConnection", "&Receiver::hidden, Qt::QueuedConnection")
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert qt_metadata(sites(result, "connect")[0])["status"] != "resolved"
+ incompatible = SOURCE.replace("void accept(int value)", "void accept(QString value)")
+ result2 = analysis(tmp_path, {"events.cpp": incompatible})
+ assert qt_metadata(sites(result2, "connect")[0])["status"] != "resolved"
+
+
+def test_computed_signal_receiver_and_custom_connect_are_not_qt_targets(tmp_path):
+ source = SOURCE.replace("emit changed(1);", "emit factory()->changed(1);")
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert qt_metadata(sites(result, "emission")[0])["status"] != "resolved"
+ custom = '''class Widget : public QObject { Q_OBJECT public:
+ void connect(QObject *a, int b, QObject *c, int d) {}
+ void run(QObject *a) { connect(a, 1, a, 2); } };'''
+ result2 = analysis(tmp_path, {"custom.cpp": custom})
+ assert not sites(result2, "connect")
+
+
+def test_const_reference_compatibility_does_not_allow_mutable_reference(tmp_path):
+ compatible = SOURCE.replace('void accept(int value)', 'void accept(const int &value)')
+ result = analysis(tmp_path, {"events.cpp": compatible})
+ assert qt_metadata(sites(result, "connect")[0])["status"] == "resolved"
+ mutable = SOURCE.replace('void accept(int value)', 'void accept(int &value)')
+ result2 = analysis(tmp_path, {"events.cpp": mutable})
+ assert qt_metadata(sites(result2, "connect")[0])["status"] != "resolved"
+
+
+def test_direct_cpp_slot_call_remains_an_ordinary_call(tmp_path):
+ source = SOURCE.replace('private slots:', 'public slots:\n void active(int value) {}\nprivate slots:').replace('QObject::disconnect(sender, &Sender::changed, receiver, &Receiver::accept);', 'QObject::disconnect(sender, &Sender::changed, receiver, &Receiver::accept);\n receiver->active(5);')
+ result = analysis(tmp_path, {"events.cpp": source})
+ active = [node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "active"][0]
+ target = qt_metadata(active)["generic_target_id"]
+ assert target
+ assert any(edge["target"] == target and edge["relation"] == "calls" for edge in result["edges"])
+ assert len(sites(result, "connect")) == 3 and len(sites(result, "disconnect")) == 1
diff --git a/tests/test_qt_explicit_emission_updates.py b/tests/test_qt_explicit_emission_updates.py
new file mode 100644
index 0000000000..03a6df0286
--- /dev/null
+++ b/tests/test_qt_explicit_emission_updates.py
@@ -0,0 +1,161 @@
+"""REQ-QML-016-AC04 explicit observations survive native declaration transitions."""
+from __future__ import annotations
+
+import os
+import stat
+
+import pytest
+
+import graphify.watch as watch
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_event_incremental_consumers import run
+from tests.test_qt_final_incremental_parity import clean, normalized, unrelated
+from tests.test_qt_product_publication import reject, snapshot
+
+
+HEADER = '''class Backend : public QObject { Q_OBJECT
+public: void run();
+signals: void missing(int value);
+};
+'''
+BODY = '''#include "events.h"
+void Backend::run() {
+ QObject *admission_only;
+ emit missing(1); Q_EMIT missing(2); missing(3);
+}
+'''
+
+
+def fixture(root):
+ """Separate declaration and event owners make header-only invalidation observable."""
+ (root / "events.h").write_bytes(HEADER.encode())
+ (root / "events.cpp").write_bytes(BODY.encode())
+ (root / "keep.py").write_text("def helper(): return 7\n\ndef retained(): return helper()\n"
+ + "\n".join(f"def keep_{index}(): return {index}" for index in range(30)), encoding="utf-8")
+ return root / "events.h", root / "events.cpp"
+
+
+def events(graph, *, resolved=True, count=None):
+ """Explicit observations retain exact identity; undeclared bare calls remain generic."""
+ selected = {identity: qt_metadata(data) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") == "emission"}
+ assert len(selected) == (count if count is not None else 3 if resolved else 2)
+ assert sum(md["explicit_emit"] for md in selected.values()) == (2 if count is None else count)
+ for identity, metadata in selected.items():
+ assert metadata["owner_id"] in graph and graph.has_edge(metadata["owner_id"], identity)
+ assert metadata["status"] == ("resolved" if resolved else "unavailable")
+ if resolved:
+ assert metadata["target_id"] in graph
+ assert qt_metadata(graph.nodes[metadata["target_id"]])["raw_name"] == "missing"
+ else:
+ assert not metadata["target_id"]
+ assert not any(data.get("relation") == "calls" for _, _, data in graph.edges(identity, data=True))
+ return selected
+
+
+def parity(graph, root, name):
+ """Cold and warm production facades compare the complete public durable graph."""
+ cache = root / (".clean-" + name)
+ assert normalized(graph) == normalized(clean(root, cache)) == normalized(clean(root, cache))
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml016_ac04_header_rename_remove_restore_and_marker_edits_preserve_observations(tmp_path, monkeypatch, operation):
+ """Header-only role removal retains explicit facts and removes their stale edges."""
+ header, body = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ before = events(initial)
+ explicit = {identity for identity, md in before.items() if md["explicit_emit"]}
+ old_target = next(md["target_id"] for md in before.values())
+ untouched = unrelated(initial)
+ parity(initial, tmp_path, "initial")
+ header.write_bytes(HEADER.replace("missing", "revised").encode())
+ renamed = run(tmp_path, monkeypatch, operation, [header])
+ assert set(events(renamed, resolved=False)) == explicit
+ assert old_target not in renamed
+ parity(renamed, tmp_path, "rename")
+
+ removed_header = HEADER.replace("signals: void missing(int value);", "")
+ header.write_bytes(removed_header.encode())
+ removed = run(tmp_path, monkeypatch, operation, [header])
+ assert set(events(removed, resolved=False)) == explicit
+ parity(removed, tmp_path, "remove")
+ header.write_bytes(HEADER.encode())
+ restored = run(tmp_path, monkeypatch, operation, [header])
+ assert normalized(restored) == normalized(initial)
+ assert set(events(restored)) == set(before)
+
+ header.write_bytes(removed_header.encode())
+ run(tmp_path, monkeypatch, operation, [header])
+ body.write_bytes(BODY.replace("emit missing(1);", "missing(1);").encode())
+ one = run(tmp_path, monkeypatch, operation, [body])
+ events(one, resolved=False, count=1)
+ parity(one, tmp_path, "one")
+ body.write_bytes(BODY.replace("emit ", "").replace("Q_EMIT ", "").encode())
+ none = run(tmp_path, monkeypatch, operation, [body])
+ events(none, resolved=False, count=0)
+ assert all(identity not in none for identity in explicit)
+ parity(none, tmp_path, "none")
+ assert unrelated(none) == untouched
+ accepted = snapshot(tmp_path / "graphify-out")
+ assert normalized(run(tmp_path, monkeypatch, operation, [header, body])) == normalized(none)
+ assert snapshot(tmp_path / "graphify-out") == accepted
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("failure", ["syntax", "publication"])
+def test_req_qml016_ac04_failed_explicit_refresh_retains_products_and_retries(tmp_path, monkeypatch, operation, failure):
+ """Actual extraction or late publication failure retains the accepted cohort."""
+ header, _ = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ explicit = {identity for identity, md in events(initial).items() if md["explicit_emit"]}
+ output = tmp_path / "graphify-out"
+ accepted = snapshot(output)
+ if failure == "syntax":
+ header.write_bytes(b"class Backend { Q_OBJECT signals: void missing( ;\n")
+ reject(tmp_path, monkeypatch, operation, header)
+ else:
+ header.write_bytes(HEADER.replace("missing", "revised").encode())
+ replace = watch.os_replace_with_fallback
+
+ def fail_late(source, destination):
+ if destination.name == "manifest.json":
+ raise OSError("public fixture publication fault")
+ return replace(source, destination)
+
+ with monkeypatch.context() as fault:
+ fault.setattr(watch, "os_replace_with_fallback", fail_late)
+ reject(tmp_path, fault, operation, header)
+ assert snapshot(output) == accepted
+ header.write_bytes(HEADER.replace("missing", "revised").encode())
+ recovered = run(tmp_path, monkeypatch, operation, [header])
+ assert set(events(recovered, resolved=False)) == explicit
+ parity(recovered, tmp_path, "recovered")
+ current = snapshot(output)
+ assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(recovered)
+ assert snapshot(output) == current
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary")
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml016_ac04_readonly_declaration_removal_preserves_graph_and_explicit_retry(tmp_path, monkeypatch, operation):
+ """A real OS denial leaves prior evidence until a corrected repeat publication."""
+ header, _ = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ explicit = {identity for identity, md in events(initial).items() if md["explicit_emit"]}
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ header.write_bytes(HEADER.replace("signals: void missing(int value);", "").encode())
+ target = output / "manifest.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ reject(tmp_path, monkeypatch, operation, header)
+ assert snapshot(output) == before
+ finally:
+ target.chmod(stat.S_IWRITE)
+ recovered = run(tmp_path, monkeypatch, operation, [header])
+ assert set(events(recovered, resolved=False)) == explicit
+ parity(recovered, tmp_path, "readonly-retry")
diff --git a/tests/test_qt_explicit_emissions.py b/tests/test_qt_explicit_emissions.py
new file mode 100644
index 0000000000..ffc824ba09
--- /dev/null
+++ b/tests/test_qt_explicit_emissions.py
@@ -0,0 +1,181 @@
+"""REQ-QML-016-AC01/AC04 explicit Qt syntax persists without declared signals."""
+import json
+
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract
+from graphify.extractors.qt_cpp_calls import calls
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_mapping import map_cpp
+from graphify.extractors.qt_cpp_syntax import read_cpp
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+
+
+SOURCE = '''class Backend : public QObject { Q_OBJECT
+public: void run() { emit missing(1); missing(2); Q_EMIT missing(3); helper(); }
+void helper() {}
+};'''
+
+
+def test_req_qml016_ac01_explicit_unknown_sites_do_not_admit_bare_calls(tmp_path):
+ """Observed annotations are two source sites; the bare invocation stays generic."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ emissions = sites(result, "emission")
+ assert len(emissions) == 2
+ assert all(qt_metadata(node)["explicit_emit"] is True for node in emissions)
+ assert all(qt_metadata(node)["status"] == "unavailable" and not qt_metadata(node)["target_id"] for node in emissions)
+ original = (tmp_path / "events.cpp").read_bytes()
+ assert [original[md["span"]["start_byte"]:md["span"]["end_byte"]]
+ for node in emissions if (md := qt_metadata(node))] == [b"missing(1)", b"missing(3)"]
+ ids = {node["id"] for node in emissions}
+ assert not any(edge["source"] in ids and edge["relation"] == "calls" for edge in result["edges"])
+
+
+@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"])
+def test_req_qml016_ac01_long_comment_crlf_bom_and_unicode_keep_original_spans(tmp_path, marker):
+ """Offset proof comes from accepted executable syntax and original disk bytes."""
+ source = ("\ufeff// café 雪\nvoid run() { " + marker + " /* " + "reason " * 40
+ + "*/\n object->missing(7); }\n").replace("\n", "\r\n")
+ result = analysis(tmp_path, {"events.cpp": source})
+ emissions = sites(result, "emission")
+ assert len(emissions) == 1
+ metadata = qt_metadata(emissions[0])
+ assert metadata["explicit_emit"] and metadata["receiver_reference"] == "object"
+ assert metadata["status"] == "unavailable" and not metadata["target_id"]
+ assert (tmp_path / "events.cpp").read_bytes()[metadata["span"]["start_byte"]:metadata["span"]["end_byte"]] == b"object->missing(7)"
+
+
+@pytest.mark.parametrize("body", [
+ '// emit missing();\n/* Q_EMIT missing(); */\n',
+ 'const char *text = "emit missing()"; const char *raw = R"tag(Q_EMIT missing())tag";',
+ 'auto value = sizeof(emit missing());',
+ 'using Value = decltype(emit missing());',
+ 'auto value = noexcept(Q_EMIT missing());',
+ 'WRAP(emit missing());',
+ '#define OBSERVATION emit missing()\n',
+ 'myemit(missing()); Q_EMIT_EXTRA(missing());',
+])
+def test_req_qml016_ac01_inert_macro_and_member_spellings_supply_no_explicit_role(tmp_path, body):
+ """Quoted, macro-owned or unevaluated names cannot create unknown Qt events."""
+ result = analysis(tmp_path, {"events.cpp": "class Backend : public QObject { Q_OBJECT public: void run() {\n" + body + "\n} };"})
+ assert not sites(result, "emission")
+
+
+def test_req_qml016_ac01_unsupported_macro_member_normalization_reports_rejection(tmp_path):
+ """An existing Qt parse-view limitation remains explicit, without invented events."""
+ path = tmp_path / "events.cpp"
+ path.write_text("class Backend : public QObject { Q_OBJECT public: void run() { object.emit(missing()); object.Q_EMIT(missing()); } };", encoding="utf-8")
+ result = extract([path], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert any(item["code"] == "QT_CPP_SYNTAX" for item in result["qml_failures"])
+ assert not sites(result, "emission")
+
+
+@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"])
+def test_req_qml016_ac01_local_macro_override_cannot_inherit_qt_annotation_role(tmp_path, marker):
+ """A preceding define/undef blocks SDK annotation proof without preprocessing."""
+ source = f"#define {marker} ordinary\nclass Backend : public QObject {{ Q_OBJECT public: void run() {{ {marker} missing(1); }} }};"
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert not sites(result, "emission")
+
+
+def test_req_qml016_ac01_macro_override_order_preserves_prior_explicit_observation(tmp_path):
+ """Later source shadowing cannot revoke an earlier lexical annotation occurrence."""
+ source = '''class Backend : public QObject { Q_OBJECT public: void run() {
+ emit missing(1);
+#undef emit
+ emit missing(2);
+} };'''
+ result = analysis(tmp_path, {"events.cpp": source})
+ emitted = sites(result, "emission")
+ assert len(emitted) == 1
+ metadata = qt_metadata(emitted[0])
+ span = metadata["span"]
+ assert (tmp_path / "events.cpp").read_bytes()[span["start_byte"]:span["end_byte"]] == b"missing(1)"
+
+
+def test_req_qml016_ac01_conditional_occurrence_keeps_uncertainty_without_delivery(tmp_path):
+ """Accepted conditional source is observed once without claiming execution."""
+ source = SOURCE.replace("emit missing(1);", "if (ready) emit missing(1);")
+ result = analysis(tmp_path, {"events.cpp": source})
+ emitted = sites(result, "emission")
+ assert len(emitted) == 2
+ assert [qt_metadata(node)["conditional"] for node in emitted] == [True, False]
+ assert all(qt_metadata(node)["status"] == "unavailable" for node in emitted)
+
+
+def test_req_qml016_ac01_declared_bare_inventory_still_observes_separate_occurrences(tmp_path):
+ """The opt-in preserves known bare signals and cannot duplicate explicit sites."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE.replace("public: void run()", "signals: void missing(int value); public: void run()")})
+ emitted = sites(result, "emission")
+ assert len(emitted) == 3 and len({node["id"] for node in emitted}) == 3
+ assert sum(qt_metadata(node)["explicit_emit"] for node in emitted) == 2
+ assert all(qt_metadata(node)["status"] == "resolved" for node in emitted)
+
+
+def test_req_qml016_ac01_default_call_collector_retains_existing_inventory_contract(tmp_path):
+ """The shared syntax scanner opts in explicitly; default callers see no extra names."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ unit = read_cpp(tmp_path / "events.cpp", tmp_path)
+ mapping = map_cpp(unit, result["nodes"], result["edges"], root=tmp_path)
+ assert not list(calls(unit, mapping, {"unrelated"}))
+ admitted = list(calls(unit, mapping, {"unrelated"}, include_explicit=True))
+ assert len(admitted) == 2 and all(item["explicit_emit"] for item in admitted)
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml016_ac04_unknown_emissions_reload_as_owned_unresolved_sites(tmp_path, directed):
+ """New facts preserve callable containment, independent of an absent endpoint."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ emissions = sites(result, "emission")
+ assert len(emissions) == 2
+ for node in emissions:
+ metadata = qt_metadata(node)
+ assert qt_metadata(reloaded.nodes[node["id"]]) == metadata
+ assert metadata["owner_id"] in reloaded and reloaded.has_edge(metadata["owner_id"], node["id"])
+ assert metadata["owner_id"] in {hit.node_id for hit in affected_nodes(reloaded, node["id"], relations=["contains"], depth=1)}
+ assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(node["id"], data=True))
+
+
+@pytest.mark.parametrize("name,marker", [("connect", "emit"), ("disconnect", "Q_EMIT")])
+@pytest.mark.parametrize("declared", [False, True])
+def test_req_qml016_ac01_explicit_annotation_owns_emission_mechanism(tmp_path, name, marker, declared):
+ """An explicit annotation takes precedence over a same-spelled Qt connection API."""
+ declaration = f"signals: void {name}();" if declared else ""
+ source = f"class Backend : public QObject {{ Q_OBJECT {declaration} public: void run() {{ {marker} {name}(); }} }};"
+ result = analysis(tmp_path, {"events.cpp": source})
+ selected = sites(result, "emission")
+ assert len(selected) == 1, [(qt_metadata(n).get("kind"), qt_metadata(n).get("raw_name")) for n in result["nodes"] if qt_metadata(n)]
+ md = qt_metadata(selected[0])
+ assert md["explicit_emit"] is True
+ assert md["status"] == ("resolved" if declared else "unavailable")
+
+
+@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"])
+def test_req_qml016_ac01_override_cannot_recover_annotation_by_prefix(tmp_path, marker):
+ """Known bare signal admission cannot reauthorize a rejected annotation token."""
+ source = f"#define {marker} ordinary\nclass Backend : public QObject {{ Q_OBJECT signals: void ready(); public: void run() {{ {marker} ready(); }} }};"
+ result = analysis(tmp_path, {"events.cpp": source})
+ selected = sites(result, "emission")
+ assert len(selected) == 1
+ assert qt_metadata(selected[0])["explicit_emit"] is False
+
+
+@pytest.mark.parametrize("marker", ["emit", "Q_EMIT"])
+def test_req_qml016_ac01_computed_receiver_keeps_full_explicit_source_site(tmp_path, marker):
+ """An outer annotated call cannot reclassify its nested factory as a bare signal."""
+ source = f"class Backend : public QObject {{ Q_OBJECT public: void run() {{ {marker} factory()->missing(1); }} }};"
+ result = analysis(tmp_path, {"events.cpp": source})
+ selected = sites(result, "emission")
+ assert len(selected) == 1
+ md = qt_metadata(selected[0])
+ assert md["raw_name"] == "missing" and md["explicit_emit"] is True
+ assert md["status"] == "unavailable" and not md["target_id"]
+ assert (tmp_path / "events.cpp").read_bytes()[md["span"]["start_byte"]:md["span"]["end_byte"]] == b"factory()->missing(1)"
diff --git a/tests/test_qt_export_matrix.py b/tests/test_qt_export_matrix.py
new file mode 100644
index 0000000000..f6bc9663d9
--- /dev/null
+++ b/tests/test_qt_export_matrix.py
@@ -0,0 +1,153 @@
+"""Qt semantic exports versus explicit presentation-only format boundaries."""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from types import SimpleNamespace
+
+import networkx as nx
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_canvas, to_cypher, to_obsidian, to_svg
+from graphify.exporters.graphdb import push_to_falkordb, push_to_neo4j
+from graphify.qt_export import qt_cypher_lines
+from tests.test_qt_qml_integration import corpus
+
+
+def actual_graph(root):
+ result = corpus(root)
+ return result, build_from_json(result, root=root)
+
+
+def test_actual_cypher_file_retains_metadata_sources_and_native_call_direction(tmp_path):
+ result, graph = actual_graph(tmp_path)
+ output = tmp_path / "native.cypher"
+ to_cypher(graph, str(output))
+ text = output.read_text(encoding="utf-8")
+ serialized = re.findall(r'`metadata_json`:("(?:[^"\\]|\\.)*")', text)
+ restored = [json.loads(json.loads(value)) for value in serialized]
+ expected = [data["metadata"] for _, data in graph.nodes(data=True) if data.get("metadata")]
+ expected += [data["metadata"] for _, _, data in graph.edges(data=True) if data.get("metadata")]
+ assert sorted(map(lambda value: json.dumps(value, sort_keys=True), restored)) == sorted(
+ map(lambda value: json.dumps(value, sort_keys=True), expected))
+ call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call")
+ match = f'MATCH (a {{id:{json.dumps(call["source"])}}}), (b {{id:{json.dumps(call["target"])}}})'
+ assert match in text
+ assert "`source_location`:" in text and "`context`:" in text and "`confidence`:" in text
+ assert "metadata_json" in text and "graphify_fact_key" in text
+
+
+@pytest.mark.parametrize("backend", ["neo4j", "falkordb"])
+def test_actual_push_driver_boundary_keeps_native_metadata_and_logical_direction(tmp_path, monkeypatch, backend):
+ result, graph = actual_graph(tmp_path)
+ calls = []
+
+ class Session:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ return False
+
+ def run(self, query, **params):
+ calls.append((query, params))
+
+ def query(self, query, params=None):
+ calls.append((query, params or {}))
+
+ if backend == "neo4j":
+ driver = SimpleNamespace(session=Session, close=lambda: None)
+ module = SimpleNamespace(GraphDatabase=SimpleNamespace(driver=lambda *_, **__: driver))
+ monkeypatch.setitem(sys.modules, "neo4j", module)
+ pushed = push_to_neo4j(graph, "unused", "", "")
+ else:
+ client = SimpleNamespace(select_graph=lambda _: Session())
+ monkeypatch.setitem(sys.modules, "falkordb", SimpleNamespace(FalkorDB=lambda **_: client))
+ pushed = push_to_falkordb(graph, "unused")
+ assert pushed == {"nodes": len(graph), "edges": graph.number_of_edges()}
+ node_props = {params["id"]: params["props"] for query, params in calls if query.startswith("MERGE (n:")}
+ for node_id, data in graph.nodes(data=True):
+ assert node_props[node_id].get("source_file") == data.get("source_file")
+ if data.get("metadata"):
+ assert json.loads(node_props[node_id]["metadata_json"]) == data["metadata"]
+ exported = {(params["src"], params["tgt"]): params["props"]
+ for query, params in calls if query.startswith("MATCH")}
+ call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call")
+ props = exported[(call["source"], call["target"])]
+ assert props["context"] == "qml_js_call" and props["confidence"] == "INFERRED"
+ assert json.loads(props["metadata_json"]) == graph.edges[call["source"], call["target"]]["metadata"]
+ assert props["graphify_source"] == call["source"] and props["graphify_target"] == call["target"]
+ assert all("fact_key" in params for query, params in calls if query.startswith("MATCH"))
+
+
+def test_cypher_keeps_parallel_mechanisms_and_escapes_literal_data(tmp_path):
+ _, base = actual_graph(tmp_path)
+ graph = nx.MultiDiGraph(base)
+ graph.add_edge("extra", "second", relation="references", context="qt_connect_signal", confidence="INFERRED")
+ graph.add_edge("extra", "second", relation="references", context="qt_disconnect_signal", confidence="INFERRED")
+ graph.nodes["extra"].update(label='quote"\\\n')
+ to_html(poisoned, {0: list(poisoned)}, str(path))
+ content = path.read_text(encoding="utf-8")
+ assert '' not in content
+ info2 = runtime_info(content, connection["id"], monkeypatch)
+ assert "<script>alert(1)</script>" in info2 and "" not in info2
+
+
+def test_qml013_ac03_existing_search_namespace_and_generic_payload_are_preserved(tmp_path):
+ """Public user attributes win; ordinary HTML nodes retain their prior schema."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE, "plain.py": 'def ordinary():\n return 1\n'})
+ graph = build_from_json(result, root=tmp_path)
+ connection = sites(result, "connect")[0]
+ graph.nodes[connection["id"]]["attributes"] = {SEARCH_NAMESPACE: {"custom": "owned"}, "keep": 1}
+ path = tmp_path / "graph.html"
+ to_html(graph, {0: list(graph)}, str(path))
+ nodes = {node["id"]: node for node in payload(path.read_text(encoding="utf-8"), "RAW_NODES")}
+ assert nodes[connection["id"]]["attributes"] == {SEARCH_NAMESPACE: {"custom": "owned"}, "keep": 1}
+ assert "qt_qml" not in nodes[connection["id"]]
+ generic = next(node for node in nodes.values() if node.get("source_file") == "plain.py")
+ assert "metadata" not in generic and "attributes" not in generic and "source_location" not in generic
+
+
+def test_qml013_ac03_aggregated_html_explicitly_states_source_fact_omission(tmp_path):
+ """An aggregate artifact cannot imply that its two communities retain facts."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ graph = build_from_json(result, root=tmp_path)
+ nodes = list(graph)
+ communities = {0: nodes[:len(nodes) // 2], 1: nodes[len(nodes) // 2:]}
+ before = copy.deepcopy(graph.graph)
+ path = tmp_path / "aggregate.html"
+ assert to_html(graph, communities, str(path), node_limit=2)
+ content = path.read_text(encoding="utf-8")
+ assert len(payload(content, "RAW_NODES")) == 2
+ assert "Qt/QML source details are omitted in this community view" in content
+ assert graph.graph == before
diff --git a/tests/test_qt_graph_persistence.py b/tests/test_qt_graph_persistence.py
new file mode 100644
index 0000000000..d2b9f34858
--- /dev/null
+++ b/tests/test_qt_graph_persistence.py
@@ -0,0 +1,55 @@
+"""Qt occurrence identities and bridge direction through actual graph/export/reload."""
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_access_providers import BACKEND
+from tests.test_qt_signals_slots import SOURCE
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_native_repeated_sites_export_with_original_utf8_crlf_spans(tmp_path, directed):
+ source = ("// Unicode café\n" + SOURCE).replace("\n", "\r\n")
+ result = analysis(tmp_path, {"events.cpp": source})
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ output = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(output), force=True)
+ reloaded = load_node_link_graph(output)
+ for site in [*sites(result, "emission"), *sites(result, "connect"), *sites(result, "disconnect")]:
+ assert site["id"] in reloaded
+ metadata = qt_metadata(reloaded.nodes[site["id"]])
+ span = metadata["span"]
+ fragment = source.encode()[span["start_byte"]:span["end_byte"]]
+ assert fragment and b"(" in fragment
+ endpoints = sites(result, "event_endpoint")
+ assert len(endpoints) == 8
+ for node in endpoints:
+ target = qt_metadata(node)["target_id"]
+ assert reloaded.has_edge(node["id"], target)
+ assert not any(attrs.get("relation") == "calls" and attrs.get("context", "").startswith("qt_connect") for _, _, attrs in reloaded.edges(data=True))
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_cpp_qml_context_calls_and_explicit_access_survive_build_export(tmp_path, directed):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = BACKEND + f'''void use(Backend *backend) {{ QQuickView view;
+ view.setInitialProperties({{{{"backend", backend}}}}); view.setSource(QUrl("{url}"));
+ auto root = view.rootObject(); QMetaObject::invokeMethod(root, "refresh");
+ QObject::connect(root, SIGNAL(closed()), backend, SLOT(close())); }}'''
+ qml = 'import QtQml\nQtObject { property var backend; function refresh() { backend.refresh() } signal closed() }'
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": qml})
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ output = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(output), force=True)
+ reloaded = load_node_link_graph(output)
+ for node in [*sites(result, "qml_load"), *sites(result, "qml_root"), *sites(result, "qml_access"), *sites(result, "context_access"), *sites(result, "event_endpoint")]:
+ metadata = qt_metadata(node)
+ assert metadata["status"] == "resolved"
+ assert reloaded.has_edge(node["id"], metadata["target_id"]), (metadata["kind"], metadata["target_id"])
+ stored = json.loads(output.read_text(encoding="utf-8"))
+ assert stored["nodes"]
diff --git a/tests/test_qt_header_adoption.py b/tests/test_qt_header_adoption.py
new file mode 100644
index 0000000000..6e1a132468
--- /dev/null
+++ b/tests/test_qt_header_adoption.py
@@ -0,0 +1,69 @@
+"""REQ-QML-018-AC07: lexical header admission and original source authority."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extract import _get_extractor, extract, extract_c, extract_cpp, extract_objc
+from graphify.extractors.qt_cpp_facts import qt_metadata
+
+
+@pytest.mark.parametrize("separator", ["\n", "\t", "\r\n", " /* comment */ "])
+@pytest.mark.parametrize("prefix", ["", "\ufeff// λ\r\n"])
+def test_req_qml018_ac07_whitespace_header_uses_cpp_source_authority(tmp_path, separator, prefix):
+ """C++ class keywords survive whitespace while authoritative bytes stay original."""
+ header = tmp_path / "backend.h"
+ source = prefix + "class" + separator + "Backend : public QObject { Q_OBJECT };\r\n"
+ header.write_bytes(source.encode("utf-8"))
+ assert _get_extractor(header) is extract_cpp
+ result = extract([header], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not result.get("qml_failures")
+ classes = [node for node in result["nodes"] if qt_metadata(node).get("kind") == "class"]
+ assert len(classes) == 1
+ metadata = qt_metadata(classes[0])
+ assert metadata["status"] == "resolved" and metadata["generic_target_id"]
+ span = metadata["span"]
+ assert header.read_bytes()[span["start_byte"]:span["end_byte"]].startswith(b"class")
+
+
+@pytest.mark.parametrize("source", [
+ '/* class Fake; namespace Ghost { } template public: :: */\nint value(void);',
+ '// class Ghost;\nconst char *label = "namespace Fake { :: public:";\n',
+ 'const char *label = "class Fake";\nstruct Backend { int value; };\n',
+ "struct Backend { int value; };\nint inspect(struct Backend *value);\n",
+ 'const char *label = R"mark(class Fake; namespace Shadow { })mark";\n',
+ '// continued comment \\ \n class Fake;\nint value;'.replace("\\ ", "\\"),
+ 'const char *label = "escaped \\" class Fake";\n',
+])
+def test_req_qml018_ac07_inert_cpp_markers_keep_c_dispatch(tmp_path, source):
+ """Quoted/commented keywords cannot authorize a parser change for C inputs."""
+ header = tmp_path / "plain.h"
+ header.write_bytes(source.encode())
+ assert _get_extractor(header) is extract_c
+
+
+@pytest.mark.parametrize("source", [
+ "struct Backend final { int value; };\n",
+ "namespace\nPublic { struct Backend { int value; }; }\n",
+ "template\n struct Backend { Value value; };\n",
+])
+def test_req_qml018_ac07_additional_cpp_declarations(tmp_path, source):
+ """Supported C++ declaration tokens select C++ without evaluating build metadata."""
+ header = tmp_path / "native.h"
+ header.write_bytes(source.encode())
+ assert _get_extractor(header) is extract_cpp
+
+
+def test_req_qml018_ac07_objective_c_retains_dispatch_priority(tmp_path):
+ """The established Objective-C boundary wins when both source forms are present."""
+ header = tmp_path / "view.h"
+ header.write_bytes(b"@interface View\n@end\nclass Backend {};\n")
+ assert _get_extractor(header) is extract_objc
+
+
+def test_req_qml018_ac07_header_prefix_budget_and_digit_separators(tmp_path):
+ """Markers outside the existing 256 KiB budget remain inconclusive; digits are lexical tokens."""
+ header = tmp_path / "large.h"
+ header.write_bytes(b" " * (256 * 1024) + b"class Backend {};\n")
+ assert _get_extractor(header) is extract_c
+ header.write_bytes(b"const long value = 1'000;\nclass\nBackend {};\n")
+ assert _get_extractor(header) is extract_cpp
diff --git a/tests/test_qt_header_updates.py b/tests/test_qt_header_updates.py
new file mode 100644
index 0000000000..be33b20d06
--- /dev/null
+++ b/tests/test_qt_header_updates.py
@@ -0,0 +1,75 @@
+"""REQ-QML-018-AC07/AC06: actual header dispatch update and durable failure."""
+from __future__ import annotations
+
+import os
+import stat
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_event_incremental_consumers import run
+from tests.test_qt_final_incremental_parity import clean, normalized
+from tests.test_qt_product_publication import reject, snapshot
+
+CPP = "\ufeff// λ\r\nclass\r\nBackend : public QObject { Q_OBJECT };\r\n"
+C = '/* class Ghost {}; :: namespace Fake {} */\nstruct Backend { int value; };\n'
+
+
+def setup(root):
+ """Generic retained sources prevent unrelated shrink policy from obscuring dispatch."""
+ header = root / "backend.h"
+ header.write_bytes(CPP.encode("utf-8"))
+ (root / "keep.py").write_text("\n".join(f"def keep_{i}(): return {i}" for i in range(30)), encoding="utf-8")
+ return header
+
+
+def cpp_targets(graph):
+ """Only an accepted canonical Qt class proves C++ header extraction occurred."""
+ return [qt_metadata(node)["generic_target_id"] for _, node in graph.nodes(data=True)
+ if node.get("source_file") == "backend.h" and qt_metadata(node).get("kind") == "class"
+ and qt_metadata(node).get("status") == "resolved"]
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac07_header_dispatch_mutation_matches_cold_and_warm(tmp_path, monkeypatch, operation):
+ """C++→C→C++ edits replace old authority without stale classes or cache aliases."""
+ header = setup(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ original = run(tmp_path, monkeypatch, operation)
+ old = cpp_targets(original)
+ assert len(old) == 1 and old[0] in original
+ header.write_bytes(C.encode())
+ plain = run(tmp_path, monkeypatch, operation, [header])
+ assert not cpp_targets(plain)
+ assert not any(node.get("metadata", {}).get("cpp_class") for _, node in plain.nodes(data=True)
+ if node.get("source_file") == "backend.h")
+ assert normalized(plain) == normalized(clean(tmp_path, tmp_path / ".clean-c"))
+ header.write_bytes(CPP.encode("utf-8"))
+ restored = run(tmp_path, monkeypatch, operation, [header])
+ assert normalized(restored) == normalized(original)
+ assert normalized(restored) == normalized(clean(tmp_path, tmp_path / ".clean-cpp"))
+ accepted = snapshot(tmp_path / "graphify-out")
+ assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(restored)
+ assert snapshot(tmp_path / "graphify-out") == accepted
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Real Windows read-only publication boundary")
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac06_header_refresh_denial_preserves_then_recovers(tmp_path, monkeypatch, operation):
+ """Actual final sidecar denial retains the accepted cohort after dispatch changed."""
+ header = setup(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ header.write_bytes(C.encode())
+ destination = output / "manifest.json"
+ destination.chmod(stat.S_IREAD)
+ try:
+ reject(tmp_path, monkeypatch, operation, header)
+ assert snapshot(output) == before
+ finally:
+ destination.chmod(stat.S_IWRITE)
+ repaired = run(tmp_path, monkeypatch, operation, [header])
+ assert not cpp_targets(repaired) and normalized(repaired) != normalized(initial)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean-recovery"))
diff --git a/tests/test_qt_html_consumers.py b/tests/test_qt_html_consumers.py
new file mode 100644
index 0000000000..b5cce6345d
--- /dev/null
+++ b/tests/test_qt_html_consumers.py
@@ -0,0 +1,54 @@
+"""QML-013-AC03 Qt event links cannot disappear or look like runtime calls."""
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.callflow_html import preferred_edges, generate_call_table_rows
+from graphify.callflow_html import write_callflow_html
+from graphify.export import to_json
+from graphify.qt_relationship_views import render_relationship_table
+from tests.qt_analysis_helpers import analysis
+from tests.test_qt_signals_slots import SOURCE
+
+
+def test_html_keeps_native_connections_and_subscriptions_with_ordinary_calls(tmp_path):
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ connection = next(edge for edge in result["edges"] if edge.get("context") == "qt_connect_signal")
+ assert connection in preferred_edges(result["edges"])
+ table = render_relationship_table(result["nodes"], result["edges"])
+ assert 'connection signal' in table and 'signal emission' in table and 'runtime delivery is unverified' in table
+ assert 'events.cpp:' in table and 'INFERRED' in table
+
+
+def test_signal_emission_is_not_rendered_as_a_caller_in_the_call_table(tmp_path):
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ emission = next(edge for edge in result["edges"] if edge.get("context") == "qt_signal_emit")
+ target = next(node for node in result["nodes"] if node["id"] == emission["target"])
+ rows = generate_call_table_rows([target], [emission], "en", [emission], result["nodes"])
+ assert "Qt emission:" not in rows
+
+
+@pytest.mark.parametrize("directed", [False, True], ids=["default-graph", "directed-graph"])
+def test_qml013_ac03_written_html_keeps_source_event_evidence_and_escapes_labels(tmp_path, directed):
+ """The saved HTML artifact exposes facts, source spans and uncertainty safely."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ connection = next(edge for edge in result["edges"] if edge.get("context") == "qt_connect_signal")
+ for _, node in graph.nodes(data=True):
+ node["community"] = 0
+ graph.nodes[connection["source"]]["label"] = ''
+ output = tmp_path / "graphify-out"
+ output.mkdir(exist_ok=True)
+ assert to_json(graph, {}, str(output / "graph.json"), force=True)
+ sections = tmp_path / "sections.json"
+ sections.write_text(json.dumps([{"id": "qt", "name": "Qt facts", "communities": [0]}]), encoding="utf-8")
+ artifact = write_callflow_html(tmp_path, sections=sections, output="graphify-out/events.html", lang="en")
+ html = artifact.read_text(encoding="utf-8")
+ assert 'class="qt-qml-relationships"' in html
+ assert 'data-source-id="' + connection["source"] + '"' in html
+ assert 'data-target-id="' + connection["target"] + '"' in html
+ assert "connection signal" in html and "signal emission" in html
+ assert "events.cpp:L" in html and "INFERRED" in html
+ assert "runtime delivery is unverified" in html
+ assert "<script>alert(1)</script>" in html and '' not in html
diff --git a/tests/test_qt_incremental_policy.py b/tests/test_qt_incremental_policy.py
new file mode 100644
index 0000000000..a088885d3e
--- /dev/null
+++ b/tests/test_qt_incremental_policy.py
@@ -0,0 +1,78 @@
+"""QML-011: accepted-input refresh, provider removal and cache/config invariants."""
+import pytest
+
+from graphify.qt_incremental import (
+ is_qt_metadata, plan_qt_refresh, qt_analysis_fingerprint, qt_syntax_cache_bypass,
+)
+
+
+@pytest.mark.parametrize("path", ["qmldir", "nested/CMakeLists.txt", "module.cmake",
+ "tools.pro", "settings.pri", "types.qmltypes", "ui.QRC"])
+def test_every_declared_qt_metadata_form_invalidates_unchanged_consumers(path):
+ plan = plan_qt_refresh(["Main.qml", "backend.h", path], [path])
+ assert plan.required
+ assert plan.accepted_inputs == tuple(sorted(["Main.qml", "backend.h", path]))
+ assert qt_syntax_cache_bypass(path)
+ assert is_qt_metadata(path)
+
+
+@pytest.mark.parametrize("path", ["qmldir.txt", "QMLDIR", "cmakelists.txt", "script.py", "notes.txt"])
+def test_unrelated_files_do_not_become_qt_metadata_or_force_scope_rebuilds(path):
+ assert not is_qt_metadata(path)
+ assert not qt_syntax_cache_bypass(path)
+ assert not plan_qt_refresh(["Main.qml", path], [path]).required
+
+
+@pytest.mark.parametrize("path", ["backend.cpp", "backend.h", "backend.hpp", "backend.cxx"])
+def test_native_provider_only_updates_include_unchanged_qml_and_cpp_consumers(path):
+ plan = plan_qt_refresh(["Main.qml", "consumer.cpp", path], [path])
+ assert plan.required
+ assert "Main.qml" in plan.accepted_inputs
+ assert "qt_native_provider_changed" in plan.reasons
+ assert not qt_syntax_cache_bypass(path)
+ assert qt_syntax_cache_bypass(path, native=True)
+
+
+def test_last_provider_deletion_and_rename_never_read_removed_or_ignored_sources():
+ prior = ["Main.qml", "backend.h", "resources.qrc"]
+ plan = plan_qt_refresh(["consumer.cpp", "new.h"], ["backend.h", "Main.qml", "resources.qrc", "new.h"],
+ prior_paths=prior)
+ assert plan.required
+ assert plan.accepted_inputs == ("consumer.cpp", "new.h")
+ assert "backend.h" not in plan.accepted_inputs
+ assert "resources.qrc" not in plan.accepted_inputs
+ assert plan_qt_refresh(["consumer.py"], ["last_native.h"]).required
+
+
+def test_ignore_and_analysis_config_changes_refresh_only_new_accepted_corpus():
+ plan = plan_qt_refresh(["Main.qml"], [".graphifyignore"], prior_paths=["backend.h", "Main.qml"])
+ assert plan.required and plan.accepted_inputs == ("Main.qml",)
+ assert "qt_corpus_policy_changed" in plan.reasons
+ assert plan_qt_refresh(["Main.qml"], [], configuration_changed=True).required
+ assert not plan_qt_refresh(["app.py"], [], configuration_changed=True).required
+
+
+def test_new_native_qt_events_are_not_hidden_by_an_ordinary_cpp_context():
+ plan = plan_qt_refresh(["ordinary.cpp", "consumer.cpp"], ["ordinary.cpp"])
+ assert plan.required
+ assert plan.accepted_inputs == ("consumer.cpp", "ordinary.cpp")
+
+
+def test_shared_js_refreshes_qt_consumers_only_when_a_qt_context_exists():
+ assert plan_qt_refresh(["Main.qml", "utils.mjs"], ["utils.mjs"]).required
+ assert not plan_qt_refresh(["app.js"], ["app.js"]).required
+ assert plan_qt_refresh(["utils.js"], ["utils.js"], qt_facts_present=True).required
+
+
+def test_configuration_fingerprint_covers_parser_contract_import_order_and_ignores():
+ base = {"parser_version": "qmljs-test-1", "fact_version": 1,
+ "import_roots": ["imports", "."], "ignore_patterns": ["build/", "generated/"]}
+ original = qt_analysis_fingerprint(**base)
+ assert qt_analysis_fingerprint(**base) == original
+ for changed in ({"parser_version": "qmljs-test-2"}, {"fact_version": 2},
+ {"import_roots": [".", "imports"]}, {"ignore_patterns": ["build/"]},
+ {"ignore_patterns": list(reversed(base["ignore_patterns"]))},
+ {"profile": "qt6-reviewed-next"}):
+ assert qt_analysis_fingerprint(**{**base, **changed}) != original
+ with pytest.raises(ValueError, match="QT_CONFIG"):
+ qt_analysis_fingerprint(parser_version="test", fact_version=0)
diff --git a/tests/test_qt_inherited_access.py b/tests/test_qt_inherited_access.py
new file mode 100644
index 0000000000..b6099b3ecd
--- /dev/null
+++ b/tests/test_qt_inherited_access.py
@@ -0,0 +1,76 @@
+"""REQ-QML-016-AC01 inheritance access is source proof, separate from meta lookup."""
+import copy
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.qt_event_index import QtEventIndex
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_inherited_endpoints import SOURCE
+
+
+@pytest.mark.parametrize("access", ["private", "protected", "default_class"])
+def test_req_qml016_ac01_external_member_pointer_cannot_cross_nonpublic_base(tmp_path, access):
+ """External C++ pointers cannot acquire public access through private/protected bases."""
+ spelling = "" if access == "default_class" else access + " "
+ source = SOURCE.replace("class Middle : public Grand", "class Middle : " + spelling + "Grand")
+ result = analysis(tmp_path, {"events.cpp": source})
+ modern, legacy = sites(result, "connect")
+ assert qt_metadata(modern)["signal_status"] == "unavailable"
+ assert qt_metadata(modern)["receiver_status"] == "unavailable"
+ # QMetaObject/legacy access and an owning-class emission do not manufacture
+ # a C++ pointer conversion. Their previously admitted mechanism is preserved.
+ assert qt_metadata(legacy)["status"] == "resolved"
+ assert qt_metadata(sites(result, "emission")[0])["status"] == "resolved"
+
+
+def test_req_qml016_ac01_explicit_grandparent_pointer_requires_public_object_conversion(tmp_path):
+ """Qualifying Grand directly does not bypass Child's private inherited base."""
+ source = SOURCE.replace("class Middle : public Grand", "class Middle : private Grand")
+ source = source.replace("&Child::changed", "&Grand::changed").replace("&Child::accept", "&Grand::accept")
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert qt_metadata(sites(result, "connect")[0])["signal_status"] == "unavailable"
+ assert qt_metadata(sites(result, "connect")[0])["signal_reason"] == "inheritance_access_unavailable"
+
+
+def test_req_qml016_ac01_struct_default_and_virtual_access_are_captured_in_source_order(tmp_path):
+ """Each comma resets default access; virtual does not replace explicit access."""
+ source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", '''class Empty { };
+struct Middle : Grand, virtual public Empty { Q_OBJECT };''')
+ result = analysis(tmp_path, {"events.cpp": source})
+ middle = next(node for node in sites(result, "class") if qt_metadata(node)["class_name"] == "Middle")
+ assert qt_metadata(middle)["canonical_base_access"] == ["public", "public"]
+ assert qt_metadata(middle)["canonical_base_access_status"] == "resolved"
+ assert qt_metadata(sites(result, "connect")[0])["status"] == "resolved"
+
+
+@pytest.mark.parametrize("invalid", ["missing", "count", "value", "shape", "status"])
+def test_req_qml016_ac01_missing_or_corrupt_base_access_rejects_pointer_only(tmp_path, invalid):
+ """Old or corrupt accepted facts cannot supply access authority from a default."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ nodes = copy.deepcopy(result["nodes"])
+ middle = next(node for node in nodes if qt_metadata(node).get("kind") == "class"
+ and qt_metadata(node)["class_name"] == "Middle")
+ update_qt(middle, **{"missing": {"canonical_base_access": None},
+ "count": {"canonical_base_access": []},
+ "value": {"canonical_base_access": ["invented"]},
+ "shape": {"canonical_base_access": [{}]},
+ "status": {"canonical_base_access_status": "unavailable"}}[invalid])
+ before = copy.deepcopy(nodes)
+ index = QtEventIndex(nodes)
+ endpoint = {"form": "member_pointer", "class_name": "Child", "member_name": "changed"}
+ result = index.member(endpoint, "Child", role="signal")
+ assert result.status == "unavailable" and result.reason == "inheritance_access_unavailable"
+ assert index.member({**endpoint, "form": "legacy"}, "Child", role="signal").status == "resolved"
+ assert nodes == before
+
+
+def test_req_qml016_ac01_class_default_resets_at_comma_and_comments_supply_no_authority(tmp_path):
+ """A private empty mixin does not hide Grand's public declaration or carry its access."""
+ source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", '''class Empty { };
+class Middle : public /* documented base */ Grand, Empty { Q_OBJECT };''')
+ result = analysis(tmp_path, {"events.cpp": source})
+ middle = next(node for node in sites(result, "class") if qt_metadata(node)["class_name"] == "Middle")
+ assert qt_metadata(middle)["canonical_base_names"] == ["Grand", "Empty"]
+ assert qt_metadata(middle)["canonical_base_access"] == ["public", "private"]
+ assert qt_metadata(sites(result, "connect")[0])["status"] == "resolved"
diff --git a/tests/test_qt_inherited_endpoints.py b/tests/test_qt_inherited_endpoints.py
new file mode 100644
index 0000000000..9e6ab95f54
--- /dev/null
+++ b/tests/test_qt_inherited_endpoints.py
@@ -0,0 +1,221 @@
+"""REQ-QML-016-AC01/AC04 accepted ancestral event endpoint identities."""
+import json
+import copy
+
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata, update_qt
+from graphify.paths import load_node_link_graph
+from graphify.qt_event_index import QtEventIndex
+from graphify.serve import _query_graph_text
+from tests.qt_analysis_helpers import analysis, sites
+
+
+SOURCE = '''class Grand : public QObject { Q_OBJECT
+signals: void changed(int value);
+public slots: void accept(int value) {}
+};
+class Middle : public Grand { Q_OBJECT };
+class Child : public Middle { Q_OBJECT
+public: void run() { emit changed(1); }
+};
+void wire(Child *sender, Child *receiver) {
+ QObject::connect(sender, &Child::changed, receiver, &Child::accept);
+ QObject::connect(sender, SIGNAL(changed(int)), receiver, SLOT(accept(int)));
+ QObject::disconnect(sender, &Child::changed, receiver, &Child::accept);
+}
+'''
+
+
+def test_req_qml016_ac01_grandparent_endpoints_use_declaring_members(tmp_path):
+ """A three-level class has one ancestor signal and receiver declaration."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ emission = sites(result, "emission")[0]
+ signal = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "changed")
+ receiver = next(node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "accept")
+ assert qt_metadata(emission)["status"] == "resolved"
+ assert qt_metadata(emission)["target_id"] == signal["id"]
+ for event in sites(result, "connect") + sites(result, "disconnect"):
+ assert qt_metadata(event)["status"] == "resolved"
+ assert qt_metadata(event)["signal_target_id"] == signal["id"]
+ assert qt_metadata(event)["receiver_target_id"] == receiver["id"]
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml016_ac04_ancestor_edges_reach_reload_query_and_affected(tmp_path, directed):
+ """Exact source bytes and typed mechanisms survive actual durable consumers."""
+ source = ("\ufeff// café 雪\n" + SOURCE).replace("\n", "\r\n")
+ result = analysis(tmp_path, {"events.cpp": source})
+ index = QtEventIndex(result["nodes"])
+ resolved = index.member({"form": "member_pointer", "class_name": "Child", "member_name": "changed"},
+ "Child", role="signal")
+ assert resolved.status == "resolved"
+ signal = next(node for node in sites(result, "member") if node["id"] == resolved.target_id)
+ raw = (tmp_path / "events.cpp").read_bytes()
+ span = qt_metadata(signal)["span"]
+ assert raw[span["start_byte"]:span["end_byte"]] == b"void changed(int value);"
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ for node in sites(result, "class"):
+ assert qt_metadata(reloaded.nodes[node["id"]]) == qt_metadata(node)
+ accepted = QtEventIndex([{"id": identity, **data} for identity, data in reloaded.nodes(data=True)])
+ assert accepted.member({"form": "member_pointer", "class_name": "Child", "member_name": "changed"},
+ "Child", role="signal").target_id == resolved.target_id
+ native_sites = sites(result, "emission") + sites(result, "connect") + sites(result, "disconnect")
+ for node in native_sites:
+ assert reloaded.nodes[node["id"]]["metadata"] == graph.nodes[node["id"]]["metadata"]
+ assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(node["id"], data=True))
+ assert reloaded.has_edge(native_sites[0]["id"], signal["id"])
+ hits = {hit.node_id for hit in affected_nodes(reloaded, signal["id"], depth=3)}
+ assert all(node["id"] in hits for node in native_sites)
+ assert qt_metadata(native_sites[0])["owner_id"] in hits
+ assert "Qt emission: changed" in _query_graph_text(reloaded, "Child changed", token_budget=4000)
+
+
+@pytest.mark.parametrize("control", ["own_ordinary", "middle_ordinary", "own_wrong_signature", "private"])
+def test_req_qml016_ac01_shadowing_precedes_role_signature_and_visibility(tmp_path, control):
+ """A first same-name declaration cannot fall through to a convenient signal."""
+ source = SOURCE
+ if control == "own_ordinary":
+ source = source.replace("public: void run()", "public: void changed(int value) {} void run()")
+ elif control == "middle_ordinary":
+ source = source.replace("public Grand { Q_OBJECT", "public Grand { Q_OBJECT public: void changed(int value) {}")
+ elif control == "own_wrong_signature":
+ source = source.replace("public: void run()", "signals: void changed(double value); public: void run()")
+ else:
+ source = source.replace("signals: void changed", "private: Q_SIGNAL void changed")
+ result = analysis(tmp_path, {"events.cpp": source})
+ events = sites(result, "connect")
+ if control in {"own_ordinary", "middle_ordinary"}:
+ assert all(qt_metadata(node)["signal_status"] != "resolved" for node in events)
+ elif control == "own_wrong_signature":
+ assert qt_metadata(events[0])["signal_status"] == "resolved"
+ target = next(node for node in result["nodes"] if node["id"] == qt_metadata(events[0])["signal_target_id"])
+ assert qt_metadata(target)["class_name"] == "Child"
+ assert qt_metadata(events[1])["signal_status"] == "unavailable"
+ else:
+ assert qt_metadata(events[0])["signal_status"] == "unavailable"
+ assert qt_metadata(events[1])["status"] == "resolved"
+
+
+@pytest.mark.parametrize("conflict", [False, True])
+def test_req_qml016_ac01_diamond_deduplicates_declarations_and_rejects_conflicts(tmp_path, conflict):
+ """Repeated canonical paths are one fact; distinct branch declarations compete."""
+ extra = "signals: void changed(int value);" if conflict else ""
+ source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", f'''class Left : public Grand {{ Q_OBJECT }};
+class Right : public Grand {{ Q_OBJECT {extra} }};
+class Middle : public Left, public Right {{ Q_OBJECT }};''')
+ result = analysis(tmp_path, {"events.cpp": source})
+ event = sites(result, "connect")[0]
+ assert qt_metadata(event)["signal_status"] == ("ambiguous" if conflict else "resolved")
+ if conflict:
+ assert len(qt_metadata(event)["signal_candidates"]) == 2
+ else:
+ target = qt_metadata(sites(result, "emission")[0])["target_id"]
+ assert qt_metadata(next(node for node in result["nodes"] if node["id"] == target))["class_name"] == "Grand"
+
+
+@pytest.mark.parametrize("control", ["forward", "duplicate", "unknown_branch", "cycle", "conditional_alias"])
+def test_req_qml016_ac01_unproven_ancestry_cannot_choose_an_endpoint(tmp_path, control):
+ """Incomplete, ambiguous, cyclic or hidden base authority rejects static joins."""
+ source = SOURCE
+ if control == "forward":
+ source = source.replace("class Middle : public Grand { Q_OBJECT };", "class Middle;")
+ elif control == "duplicate":
+ source += "\nclass Middle : public Grand { Q_OBJECT };\n"
+ elif control == "unknown_branch":
+ source = source.replace("public Middle {", "public Middle, public Missing {")
+ elif control == "cycle":
+ source = source.replace("public Grand {", "public Child {")
+ else:
+ source = source.replace("class Middle : public Grand", "#if SELECT\nusing Base = Grand;\n#endif\nclass Middle : public Base")
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert all(qt_metadata(node).get("signal_status") != "resolved" for node in sites(result, "connect"))
+ assert qt_metadata(sites(result, "emission")[0])["status"] != "resolved"
+
+
+@pytest.mark.parametrize("depth,expected", [(31, "resolved"), (32, "unavailable")])
+def test_req_qml016_ac01_ancestor_traversal_has_a_fail_closed_32_class_budget(tmp_path, depth, expected):
+ """The last admitted class resolves; an additional class is never truncated to proof."""
+ chain = "class Grand : public QObject { Q_OBJECT signals: void changed(int value); };\n"
+ previous = "Grand"
+ for number in range(depth):
+ current = f"Level{number}"
+ chain += f"class {current} : public {previous} {{ Q_OBJECT }};\n"
+ previous = current
+ chain += f"void wire({previous} *object) {{ QObject::connect(object, &{previous}::changed, object, [](int value){{}}); }}"
+ result = analysis(tmp_path, {"events.cpp": chain})
+ assert qt_metadata(sites(result, "connect")[0])["signal_status"] == expected
+ if expected == "unavailable":
+ assert qt_metadata(sites(result, "connect")[0])["signal_reason"] == "inheritance_cycle_or_limit"
+
+
+def test_req_qml016_ac01_lexical_namespace_and_alias_bases_keep_exact_authority(tmp_path):
+ """An unrelated global class cannot replace a namespaced aliased ancestor."""
+ source = "namespace Public {\n" + SOURCE.replace("class Middle : public Grand", "using Alias = Grand;\nclass Middle : public Alias") + "}\n"
+ source += "class Grand: public QObject {Q_OBJECT signals: void changed(double value);};"
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert all(qt_metadata(event)["status"] == "resolved" for event in sites(result, "connect"))
+ target = qt_metadata(sites(result, "emission")[0])["target_id"]
+ assert qt_metadata(next(node for node in result["nodes"] if node["id"] == target))["class_name"] == "Public::Grand"
+
+
+@pytest.mark.parametrize("selected", [False, True])
+def test_req_qml016_ac01_ancestor_overloads_require_the_literal_selector(tmp_path, selected):
+ """Recursive lookup preserves signature selection rather than choosing one overload."""
+ source = SOURCE.replace("void changed(int value);", "void changed(int value); void changed(double value);")
+ if selected:
+ source = source.replace("&Child::changed", "qOverload(&Child::changed)")
+ result = analysis(tmp_path, {"events.cpp": source})
+ event = sites(result, "connect")[0]
+ assert qt_metadata(event)["signal_status"] == ("resolved" if selected else "ambiguous")
+ assert qt_metadata(sites(result, "connect")[1])["status"] == "resolved"
+ if selected:
+ target = qt_metadata(event)["signal_target_id"]
+ assert qt_metadata(next(node for node in result["nodes"] if node["id"] == target))["parameter_types"] == ["int"]
+
+
+def test_req_qml016_ac01_competing_branch_role_cannot_disambiguate_cpp_name(tmp_path):
+ """An ordinary branch member still competes with a differently owned signal."""
+ source = SOURCE.replace("class Middle : public Grand { Q_OBJECT };", '''class Other : public QObject {
+Q_OBJECT public: void changed(int value) {}
+};
+class Middle : public Grand, public Other { Q_OBJECT };''')
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert all(qt_metadata(event)["signal_status"] == "ambiguous" for event in sites(result, "connect"))
+
+
+@pytest.mark.parametrize("control", ["base_status", "base_count", "base_shape", "class_status", "member_status"])
+def test_req_qml016_ac01_reloaded_incomplete_proof_is_rejected_without_mutation(tmp_path, control):
+ """Corrupted accepted transport or status cannot synthesize an ancestral edge."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ nodes = copy.deepcopy(result["nodes"])
+ if control == "member_status":
+ node = next(node for node in nodes if qt_metadata(node).get("raw_name") == "changed")
+ update_qt(node, status="unavailable")
+ else:
+ node = next(node for node in nodes if qt_metadata(node).get("kind") == "class"
+ and qt_metadata(node)["class_name"] == "Middle")
+ update_qt(node, **{"base_status": {"canonical_base_status": "unavailable"},
+ "base_count": {"canonical_base_names": []},
+ "base_shape": {"bases": None},
+ "class_status": {"status": "ambiguous"}}[control])
+ before = copy.deepcopy(nodes)
+ answer = QtEventIndex(nodes).member({"form": "member_pointer", "class_name": "Child", "member_name": "changed"},
+ "Child", role="signal")
+ assert answer.status == "unavailable" and nodes == before
+
+
+def test_req_qml016_ac01_explicit_ancestor_qualification_requires_receiver_compatibility(tmp_path):
+ """Grandparent qualification accepts Child identity and rejects an unrelated object."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE + "class Other : public QObject { Q_OBJECT };"})
+ index = QtEventIndex(result["nodes"])
+ endpoint = {"form": "member_pointer", "class_name": "Grand", "member_name": "changed"}
+ assert index.member(endpoint, "Child", role="signal").status == "resolved"
+ answer = index.member(endpoint, "Other", role="signal")
+ assert answer.status == "unavailable" and answer.reason == "object_member_type_mismatch"
diff --git a/tests/test_qt_inherited_updates.py b/tests/test_qt_inherited_updates.py
new file mode 100644
index 0000000000..3e86661b54
--- /dev/null
+++ b/tests/test_qt_inherited_updates.py
@@ -0,0 +1,165 @@
+"""REQ-QML-016-AC04 inherited endpoint refresh, retention and recovery."""
+from __future__ import annotations
+
+import os
+import stat
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_event_incremental_consumers import run
+from tests.test_qt_final_incremental_parity import clean, normalized, unrelated
+from tests.test_qt_product_publication import reject, snapshot
+
+
+HEADER = '''class Grand : public QObject { Q_OBJECT
+signals: void changed(int value);
+public slots: void accept(int value) {}
+};
+class Middle : public Grand { Q_OBJECT };
+class Child : public Middle { Q_OBJECT public: void run(); };
+'''
+BODY = '''#include "events.h"
+void Child::run() { emit changed(1); }
+void wire(Child *sender, Child *receiver) {
+ QObject::connect(sender, &Child::changed, receiver, &Child::accept);
+ QObject::connect(sender, SIGNAL(changed(int)), receiver, SLOT(accept(int)));
+}
+'''
+
+
+def fixture(root):
+ """Header declarations own targets; unchanged implementation owns event sites."""
+ (root / "events.h").write_bytes(HEADER.encode())
+ (root / "events.cpp").write_bytes(BODY.encode())
+ (root / "keep.py").write_text("def helper(): return 7\n\ndef retained(): return helper()\n"
+ + "\n".join(f"def keep_{index}(): return {index}" for index in range(20)), encoding="utf-8")
+ return root / "events.h", root / "events.cpp"
+
+
+def endpoints(graph, expected: str | None = "changed", event_count=3):
+ """Connections and emissions preserve explicit roles and source declaring class."""
+ events = {identity: qt_metadata(data) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") in {"connect", "emission"}}
+ assert len(events) == event_count
+ for identity, metadata in events.items():
+ if expected is None:
+ assert metadata["status"] != "resolved"
+ assert not metadata.get("signal_target_id") and not metadata.get("target_id")
+ else:
+ assert metadata["status"] == "resolved"
+ target = metadata.get("signal_target_id") or metadata.get("target_id")
+ declaration = qt_metadata(graph.nodes[target])
+ assert declaration["class_name"] == "Grand" and declaration["raw_name"] == expected
+ assert graph.nodes[target]["source_file"] == "events.h"
+ assert not any(data.get("relation") == "calls" for _, _, data in graph.edges(identity, data=True))
+ return events
+
+
+def parity(graph, root, key):
+ """A fresh and a reused AST cache must produce the entire same public graph."""
+ cache = root / (".clean-" + key)
+ assert normalized(graph) == normalized(clean(root, cache)) == normalized(clean(root, cache))
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml016_ac04_header_signal_base_and_site_edits_match_full_cold_warm(tmp_path, monkeypatch, operation):
+ """Header-only edits rejoin existing sites; removal eliminates stale endpoint facts."""
+ header, body = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ endpoints(initial)
+ parity(initial, tmp_path, "initial")
+ untouched = unrelated(initial)
+ old_signal = next(md.get("signal_target_id") or md["target_id"] for md in endpoints(initial).values())
+
+ header.write_bytes(HEADER.replace("changed", "revised").encode())
+ changed = run(tmp_path, monkeypatch, operation, [header])
+ # INC-QML-28 retains explicit source syntax after declaration removal;
+ # the emission and connection observations cannot retain old targets.
+ endpoints(changed, expected=None)
+ parity(changed, tmp_path, "header")
+ assert old_signal not in changed
+ body.write_bytes(BODY.replace("changed", "revised").encode())
+ repaired = run(tmp_path, monkeypatch, operation, [body])
+ endpoints(repaired, expected="revised")
+ parity(repaired, tmp_path, "repair")
+
+ header.write_bytes(HEADER.replace("changed", "revised").replace("public Grand", "public Missing").encode())
+ disconnected = run(tmp_path, monkeypatch, operation, [header])
+ endpoints(disconnected, expected=None)
+ parity(disconnected, tmp_path, "base")
+ header.write_bytes(HEADER.replace("changed", "revised").encode())
+ restored = run(tmp_path, monkeypatch, operation, [header])
+ assert normalized(restored) == normalized(repaired)
+
+ # Inheritance access is a source fact and header-only invalidation input.
+ # Nonpublic bases reject external C++ pointers while legacy meta lookup
+ # retains its independently admitted declaration identity.
+ header.write_bytes(HEADER.replace("changed", "revised").replace("public Grand", "private Grand").encode())
+ restricted = run(tmp_path, monkeypatch, operation, [header])
+ connections = [qt_metadata(data) for _, data in restricted.nodes(data=True)
+ if qt_metadata(data).get("kind") == "connect"]
+ modern = next(md for md in connections if md["signal"]["form"] == "member_pointer")
+ legacy = next(md for md in connections if md["signal"]["form"] == "legacy")
+ assert modern["status"] == "unavailable" and modern["signal_reason"] == "inheritance_access_unavailable"
+ assert legacy["status"] == "resolved"
+ parity(restricted, tmp_path, "access")
+ header.write_bytes(HEADER.replace("changed", "revised").encode())
+ assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(repaired)
+
+ removed_text = BODY.replace("emit changed(1);", "")
+ removed_text = "\n".join(line for line in removed_text.splitlines() if "QObject::connect" not in line)
+ body.write_bytes(removed_text.encode())
+ removed = run(tmp_path, monkeypatch, operation, [body])
+ parity(removed, tmp_path, "removed")
+ assert all(identity not in removed for identity in endpoints(restored, expected="revised"))
+ assert not any(qt_metadata(data).get("kind") == "event_endpoint" for _, data in removed.nodes(data=True))
+ assert unrelated(removed) == untouched
+ current = snapshot(tmp_path / "graphify-out")
+ assert normalized(run(tmp_path, monkeypatch, operation, [header, body])) == normalized(removed)
+ assert snapshot(tmp_path / "graphify-out") == current
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml016_ac04_malformed_ancestor_preserves_products_then_retries(tmp_path, monkeypatch, operation):
+ """Rejected native source cannot advance durable endpoint evidence or sidecars."""
+ header, _ = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ endpoints(initial)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ header.write_bytes(b"class Grand : public QObject { Q_OBJECT signals: void changed( ;\n")
+ reject(tmp_path, monkeypatch, operation, header)
+ assert snapshot(output) == before
+ header.write_bytes(HEADER.encode())
+ recovered = run(tmp_path, monkeypatch, operation, [header])
+ assert normalized(recovered) == normalized(initial)
+ parity(recovered, tmp_path, "syntax-retry")
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only publication boundary")
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml016_ac04_readonly_ancestor_refresh_preserves_cohort_and_retry(tmp_path, monkeypatch, operation):
+ """A real late sidecar denial retains graph/root/manifest/Qt stamp before recovery."""
+ header, _ = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ header.write_bytes(HEADER.replace("changed", "revised").encode())
+ target = output / "manifest.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ reject(tmp_path, monkeypatch, operation, header)
+ assert snapshot(output) == before
+ finally:
+ target.chmod(stat.S_IWRITE)
+ recovered = run(tmp_path, monkeypatch, operation, [header])
+ endpoints(recovered, expected=None)
+ assert normalized(recovered) != normalized(initial)
+ parity(recovered, tmp_path, "publication-retry")
+ accepted = snapshot(output)
+ assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(recovered)
+ assert snapshot(output) == accepted
diff --git a/tests/test_qt_initial_repeat.py b/tests/test_qt_initial_repeat.py
new file mode 100644
index 0000000000..8aae3b6be4
--- /dev/null
+++ b/tests/test_qt_initial_repeat.py
@@ -0,0 +1,156 @@
+"""INC-QML-32: first raw publication remains byte- and mtime-stable on a repeat."""
+from __future__ import annotations
+
+import copy
+import json
+import os
+
+import pytest
+
+import graphify.__main__ as entrypoint
+import graphify.watch as watch
+from graphify.paths import load_node_link_graph
+from tests.qt_combined_adoption_fixture import corpus
+from tests.test_qt_combined_adoption import accepted
+
+
+RUN_FIELDS = ("diagnostics", "failed_sources", "qml_failures", "qt_failures", "hyperedges")
+
+
+def update(root, monkeypatch, operation, changes=None):
+ """Exercise the ordinary raw CLI/watch publication without prewarming extraction."""
+ if operation == "manual":
+ monkeypatch.setattr(entrypoint, "_check_skill_version", lambda *_: None)
+ monkeypatch.setattr(entrypoint, "_refresh_stale_skills", lambda: None)
+ monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", str(root), "--no-cluster"])
+ entrypoint.main()
+ return True
+ return watch._rebuild_code(root, changed_paths=changes, no_cluster=True)
+
+
+def pin_graph_mtime(path):
+ # An explicit old timestamp detects even a same-content replacement on
+ # filesystems whose normal timestamp precision hides fast repeat writes.
+ before = path.stat()
+ os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns - 10_000_000_000))
+ return path.read_bytes(), path.stat().st_mtime_ns
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("build_system", ["cmake", "qmake"])
+@pytest.mark.parametrize("scope", ["whole", "subfolder"])
+def test_cold_combined_no_cluster_repeat_keeps_bytes_mtime_and_changed_source(tmp_path, monkeypatch,
+ operation, build_system, scope):
+ """The first real Qt publication must survive an unchanged run, while a genuine edit is published."""
+ app = corpus(tmp_path, build_system)
+ root = tmp_path if scope == "whole" else app
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["app/imports"]' if scope == "whole" else '["imports"]')
+ assert update(root, monkeypatch, operation)
+ path = root / "graphify-out/graph.json"
+ accepted(load_node_link_graph(path))
+ original, timestamp = pin_graph_mtime(path)
+ assert update(root, monkeypatch, operation)
+ assert path.read_bytes() == original and path.stat().st_mtime_ns == timestamp
+ # Repeated success must never mask a real native/QML source fact change.
+ source = app / "Main.qml"
+ source.write_bytes(source.read_bytes().replace(b"handleReady", b"received"))
+ assert update(root, monkeypatch, operation, [source])
+ assert path.read_bytes() != original and path.stat().st_mtime_ns != timestamp
+ accepted(load_node_link_graph(path))
+ edited, timestamp = pin_graph_mtime(path)
+ assert update(root, monkeypatch, operation)
+ assert path.read_bytes() == edited and path.stat().st_mtime_ns == timestamp
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_cold_generic_no_cluster_repeat_does_not_rewrite_first_graph(tmp_path, monkeypatch, operation):
+ """Unrelated Python keeps the same first-publication promise through both production drivers."""
+ (tmp_path / "keep.py").write_bytes(b"def helper(): return 7\n\ndef retained(): return helper()\n")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ assert update(tmp_path, monkeypatch, operation)
+ path = tmp_path / "graphify-out/graph.json"
+ original, timestamp = pin_graph_mtime(path)
+ assert update(tmp_path, monkeypatch, operation)
+ assert path.read_bytes() == original and path.stat().st_mtime_ns == timestamp
+
+
+@pytest.mark.parametrize("field", RUN_FIELDS)
+def test_known_absent_and_empty_run_lists_compare_equal_without_mutation(field):
+ """Only explicitly owned empty-list omissions are representation-equivalent."""
+ raw = {"nodes": [{"id": "kept", "metadata": {"source_proof": {"span": [1, 9]}}}], "links": []}
+ populated = {**raw, field: [], "extracted_sources": ["first.cpp"], "built_at_commit": "first"}
+ before = copy.deepcopy(populated)
+ assert watch._canonical_graph_for_compare(raw) == watch._canonical_graph_for_compare(populated)
+ assert populated == before
+
+
+@pytest.mark.parametrize("field", RUN_FIELDS)
+@pytest.mark.parametrize("value", [[{"code": "QT_DECLARATION_CONFLICT", "line": 1}], None, {}, "", 0])
+def test_nonempty_or_wrong_type_run_lists_remain_observable(field, value):
+ """Meaningful diagnostics, failures, hyperedges and malformed transport are never stripped."""
+ raw = {"nodes": [], "links": []}
+ populated = {**raw, field: value}
+ assert watch._canonical_graph_for_compare(raw) != watch._canonical_graph_for_compare(populated)
+
+
+@pytest.mark.parametrize("change", ["unknown", "node_proof", "edge_proof", "native_identity"])
+def test_unknown_metadata_and_source_facts_still_compare_different(change):
+ """The comparison cannot blanket-strip graph metadata or any native/source relationship proof."""
+ raw: dict = {"nodes": [{"id": "kept", "metadata": {"source_proof": {"span": [1, 9]}}}],
+ "links": [{"source": "kept", "target": "other", "metadata": {"native_endpoint": "actual"}}]}
+ edited = copy.deepcopy(raw)
+ if change == "unknown":
+ edited["extension_policy"] = {"revision": 2}
+ elif change == "node_proof":
+ edited["nodes"][0]["metadata"]["source_proof"]["span"] = [2, 9]
+ elif change == "edge_proof":
+ edited["links"][0]["metadata"]["native_endpoint"] = "different"
+ else:
+ edited["links"][0]["target"] = "different"
+ assert watch._canonical_graph_for_compare(raw) != watch._canonical_graph_for_compare(edited)
+
+
+def test_topology_comparison_keeps_existing_stage_metadata_contract():
+ """The clustered topology shortcut is deliberately outside this normalization owner."""
+ raw = {"nodes": [], "links": []}
+ populated = {**raw, "diagnostics": [], "hyperedges": [], "extracted_sources": ["first.cpp"]}
+ assert watch._canonical_topology_for_compare(raw) != watch._canonical_topology_for_compare(populated)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_real_raw_publication_failure_retains_prior_then_recovers(tmp_path, monkeypatch, operation):
+ """Normalization retains the existing failed replacement and recovery behavior for genuine edits."""
+ source = tmp_path / "keep.py"
+ source.write_bytes(b"def original(): return 7\n")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ assert update(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ path = output / "graph.json"
+ original, timestamp = pin_graph_mtime(path)
+ prior = {name: (output / name).read_bytes() for name in ("manifest.json", ".qt_analysis.json", ".graphify_root")}
+ source.write_bytes(b"def renamed(): return 9\n")
+ replace = watch.os_replace_with_fallback
+
+ def reject_graph(staged, destination, **kwargs):
+ if os.fspath(destination).endswith("graph.json"):
+ raise OSError("simulated graph replacement failure")
+ return replace(staged, destination, **kwargs)
+
+ with monkeypatch.context() as fault:
+ fault.setattr(watch, "os_replace_with_fallback", reject_graph)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ update(tmp_path, monkeypatch, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not update(tmp_path, monkeypatch, operation, [source])
+ assert path.read_bytes() == original and path.stat().st_mtime_ns == timestamp
+ assert all((output / name).read_bytes() == content for name, content in prior.items())
+ assert not list(output.glob(".gfy-publish-*"))
+ assert update(tmp_path, monkeypatch, operation, [source])
+ assert path.read_bytes() != original
+ assert any(node.get("label") == "renamed()" for node in json.loads(path.read_bytes())["nodes"])
diff --git a/tests/test_qt_json_direction.py b/tests/test_qt_json_direction.py
new file mode 100644
index 0000000000..a26cfe4558
--- /dev/null
+++ b/tests/test_qt_json_direction.py
@@ -0,0 +1,185 @@
+"""INC-QML-31: JSON export rejects forged direction before durable publication."""
+from __future__ import annotations
+
+import copy
+import json
+import sys
+from types import SimpleNamespace
+
+import networkx as nx
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.export import to_cypher, to_json
+from graphify.exporters.graphdb import push_to_falkordb, push_to_neo4j
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_export import preflight_qt_payload, qt_cypher_lines
+from tests.qt_analysis_helpers import analysis
+from tests.test_qt_qml_integration import corpus
+
+
+def actual_graph(root, directed=False):
+ """Accepted original QML source supplies real typed reference and source-owner facts."""
+ result = analysis(root, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }"})
+ graph = build_from_json(result, root=root, directed=directed)
+ source = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "read")
+ target = next(node["id"] for node in result["nodes"] if qml_metadata(node).get("kind") == "property"
+ and qml_metadata(node).get("raw_name") == "input")
+ return graph, source, target
+
+
+def damage(data, corruption, source, target):
+ """Mutate only marker transport, preserving the actual accepted graph endpoint pair."""
+ data["_src"], data["_tgt"] = source, target
+ if corruption == "partial_source":
+ data.pop("_tgt")
+ elif corruption == "partial_target":
+ data.pop("_src")
+ elif corruption == "foreign_source":
+ data["_src"] = "private-needle\nforged diagnostic"
+ elif corruption == "foreign_target":
+ data["_tgt"] = "foreign"
+ elif corruption == "null":
+ data["_src"] = None
+ elif corruption == "list":
+ data["_src"] = [source]
+ elif corruption == "mapping":
+ data["_tgt"] = {"id": target}
+ elif corruption == "collapsed":
+ data["_tgt"] = source
+ else:
+ data["_src"], data["_tgt"] = target, source
+
+
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("force", [False, True])
+@pytest.mark.parametrize("corruption", ["partial_source", "partial_target", "foreign_source", "foreign_target",
+ "null", "list", "mapping", "collapsed"])
+def test_json_direction_rejection_preserves_previous_bytes_and_safe_diagnostic(tmp_path, capsys,
+ directed, force, corruption):
+ """Neither explicit force nor malformed marker types can replace the previous accepted graph."""
+ graph, source, target = actual_graph(tmp_path, directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path), built_at_commit="fixed")
+ accepted = path.read_bytes()
+ edge = graph.edges[source, target]
+ damage(edge, corruption, source, target)
+ before = copy.deepcopy(nx.node_link_data(graph, edges="links"))
+ assert to_json(graph, {}, str(path), force=force, built_at_commit="fixed") is False
+ assert path.read_bytes() == accepted
+ assert nx.node_link_data(graph, edges="links") == before
+ diagnostic = capsys.readouterr().err
+ assert "QT_EXPORT_DIRECTION" in diagnostic
+ assert "private-needle" not in diagnostic and "forged diagnostic" not in diagnostic
+ assert str(path) not in diagnostic
+ # Repair uses the same production writer; repeated success preserves bytes
+ # and the actual read remains a dependency after the durable reload.
+ edge["_src"], edge["_tgt"] = source, target
+ assert to_json(graph, {}, str(path), built_at_commit="fixed")
+ assert path.read_bytes() == accepted
+ loaded = load_node_link_graph(path)
+ assert source in {hit.node_id for hit in affected_nodes(loaded, target, depth=1)}
+
+
+def test_json_directed_contradiction_rejects_before_first_file_exists(tmp_path, capsys):
+ """A directed graph cannot reverse its real accepted pair; refusal creates no partial JSON."""
+ graph, source, target = actual_graph(tmp_path, directed=True)
+ damage(graph.edges[source, target], "reversed", source, target)
+ path = tmp_path / "first.json"
+ assert to_json(graph, {}, str(path), force=True) is False
+ assert not path.exists() and "QT_EXPORT_DIRECTION" in capsys.readouterr().err
+
+
+@pytest.mark.parametrize("graph_type", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph])
+def test_legacy_unmarked_edges_and_valid_self_pairs_preserve_native_identity(tmp_path, graph_type):
+ """Generic graphs retain native direction, loops, parallel facts and original values."""
+ graph = graph_type()
+ graph.add_edge("caller", "callee", relation="calls", context="ordinary", confidence="EXTRACTED")
+ graph.add_edge("caller", "caller", relation="references", _src="caller", _tgt="caller")
+ if graph.is_multigraph():
+ graph.add_edge("caller", "callee", relation="references", context="second")
+ before = copy.deepcopy(nx.node_link_data(graph, edges="links"))
+ path = tmp_path / "legacy.json"
+ assert to_json(graph, {}, str(path), built_at_commit="fixed")
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ assert payload["directed"] == graph.is_directed() and payload["multigraph"] == graph.is_multigraph()
+ assert len(payload["links"]) == graph.number_of_edges()
+ assert all((edge["source"], edge["target"]) in {(u, v) for u, v in graph.edges()} for edge in payload["links"])
+ assert nx.node_link_data(graph, edges="links") == before
+ assert to_json(graph, {}, str(path), built_at_commit="fixed")
+
+
+def test_undirected_reversed_storage_retains_real_qml_dependency_direction(tmp_path):
+ """Reversing physical insertion order must not reverse an accepted QML read after reload."""
+ graph, source, target = actual_graph(tmp_path)
+ reversed_graph = nx.Graph()
+ reversed_graph.add_node(target, **graph.nodes[target])
+ reversed_graph.add_nodes_from((identity, data) for identity, data in graph.nodes(data=True) if identity != target)
+ reversed_graph.add_edges_from((v, u, copy.deepcopy(data)) for u, v, data in graph.edges(data=True))
+ assert next((u, v) for u, v in reversed_graph.edges() if {u, v} == {source, target}) == (target, source)
+ path = tmp_path / "reversed.json"
+ assert to_json(reversed_graph, {}, str(path))
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ edge = next(data for data in payload["links"] if data.get("context") == "qml_binding_read")
+ assert (edge["source"], edge["target"]) == (source, target)
+ assert source in {hit.node_id for hit in affected_nodes(load_node_link_graph(path), target, depth=1)}
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_native_source_proof_export_reload_consumer_survives_rejection_and_repair(tmp_path, directed):
+ """Actual C++ registration joins preserve exact native endpoint proof across the corrected boundary."""
+ result = corpus(tmp_path)
+ call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call")
+ source, target = call["source"], call["target"]
+ assert qt_metadata(call)["native_endpoint"]["canonical_target_id"] == target
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "native.json"
+ assert to_json(graph, {}, str(path), built_at_commit="fixed")
+ accepted = path.read_bytes()
+ edge = graph.edges[source, target]
+ damage(edge, "foreign_source", source, target)
+ assert to_json(graph, {}, str(path), force=True) is False and path.read_bytes() == accepted
+ edge["_src"], edge["_tgt"] = source, target
+ assert to_json(graph, {}, str(path), built_at_commit="fixed") and path.read_bytes() == accepted
+ restored = load_node_link_graph(path)
+ assert qt_metadata(restored.edges[source, target])["native_endpoint"] == qt_metadata(call)["native_endpoint"]
+ assert restored.edges[source, target]["source_location"] == call["source_location"]
+ assert source in {hit.node_id for hit in affected_nodes(restored, target, depth=1)}
+
+
+@pytest.mark.parametrize("corruption", ["partial_source", "partial_target", "foreign_source", "foreign_target",
+ "null", "list", "mapping", "collapsed"])
+def test_external_direction_rejection_precedes_file_or_database_connection(tmp_path, monkeypatch, corruption):
+ """External preflight shares strict malformed-pair rejection without printing untrusted values."""
+ graph, source, target = actual_graph(tmp_path)
+ damage(graph.edges[source, target], corruption, source, target)
+ before = copy.deepcopy(nx.node_link_data(graph, edges="links"))
+ path = tmp_path / "previous.cypher"
+ path.write_bytes(b"previous valid export")
+
+ def forbidden(*_, **__):
+ pytest.fail("Direction rejection must precede database creation or connection")
+
+ monkeypatch.setitem(sys.modules, "neo4j", SimpleNamespace(GraphDatabase=SimpleNamespace(driver=forbidden)))
+ monkeypatch.setitem(sys.modules, "falkordb", SimpleNamespace(FalkorDB=forbidden))
+ for action in (lambda: preflight_qt_payload(graph), lambda: qt_cypher_lines(graph),
+ lambda: to_cypher(graph, str(path)), lambda: push_to_neo4j(graph, "unused", "", ""),
+ lambda: push_to_falkordb(graph, "unused")):
+ with pytest.raises(ValueError, match="QT_EXPORT_DIRECTION") as rejected:
+ action()
+ assert "private-needle" not in str(rejected.value) and "forged diagnostic" not in str(rejected.value)
+ assert path.read_bytes() == b"previous valid export" and nx.node_link_data(graph, edges="links") == before
+
+
+def test_external_bidirectional_wrapper_honors_logical_pair_but_json_requires_native_direction(tmp_path):
+ """The external adapter retains its wrapper contract while JSON refuses contradictory native identity."""
+ graph, source, target = actual_graph(tmp_path)
+ wrapper = nx.MultiDiGraph(graph)
+ preflight_qt_payload(wrapper)
+ lines = qt_cypher_lines(wrapper)
+ assert lines and any(f'id:{json.dumps(source)}' in line and f'id:{json.dumps(target)}' in line for line in lines)
+ path = tmp_path / "wrapper.json"
+ assert to_json(wrapper, {}, str(path), force=True) is False and not path.exists()
diff --git a/tests/test_qt_json_direction_updates.py b/tests/test_qt_json_direction_updates.py
new file mode 100644
index 0000000000..71facb69f6
--- /dev/null
+++ b/tests/test_qt_json_direction_updates.py
@@ -0,0 +1,117 @@
+"""INC-QML-31: rejected JSON direction preserves the real manual/watch cohort."""
+from __future__ import annotations
+
+from collections import Counter
+import json
+
+import pytest
+
+import graphify.__main__ as entrypoint
+import graphify.export as export
+import graphify.watch as watch
+from graphify.affected import affected_nodes
+from graphify.cache import cache_dir
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis
+from tests.test_qt_final_incremental_parity import clean, normalized
+
+
+PRODUCTS = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json", "GRAPH_REPORT.md",
+ ".graphify_labels.json", ".graphify_labels.json.sig")
+
+
+def snapshot(output):
+ return {name: (output / name).read_bytes() for name in PRODUCTS}
+
+
+def source_contract(graph, *, only_python=False):
+ """Compare exact source facts while excluding the clustered display label."""
+ nodes, edges = normalized(graph)
+
+ def source_data(serialized):
+ data = json.loads(serialized)
+ # Cold analysis has no communities. This presentation label is supplied
+ # by clustering; direction, source proof and every other fact remain.
+ data.pop("community_name", None)
+ return json.dumps(data, sort_keys=True, ensure_ascii=False)
+
+ identities = {identity for identity, data in graph.nodes(data=True)
+ if not only_python or data.get("source_file") == "keep.py"}
+ return ({identity: source_data(data) for identity, data in nodes.items() if identity in identities},
+ Counter({(source, target, source_data(data)): count for (source, target, data), count in edges.items()
+ if source in identities and target in identities}))
+
+
+def clustered(root, monkeypatch, operation, changes=None):
+ """Both real entry points use clustered JSON export, not the raw no-cluster writer."""
+ if operation == "manual":
+ monkeypatch.setattr(entrypoint, "_check_skill_version", lambda *_: None)
+ monkeypatch.setattr(entrypoint, "_refresh_stale_skills", lambda: None)
+ monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", str(root)])
+ entrypoint.main()
+ else:
+ assert watch._rebuild_code(root, changed_paths=changes)
+ return load_node_link_graph(root / "graphify-out/graph.json")
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("corruption", ["foreign", "partial"])
+def test_rejected_direction_clustered_publication_retains_products_cache_and_repairs(tmp_path, monkeypatch,
+ capsys, operation, corruption):
+ """Only marker transport is faulted at the real graph serializer; disk publication must reject then recover."""
+ analysis(tmp_path, {"Main.qml": "import QtQml\nQtObject { property int input: 1; property int output: input }",
+ "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n"})
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = clustered(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ prior = snapshot(output)
+ kept = source_contract(initial, only_python=True)
+ ast = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")}
+ assert ast
+ source = tmp_path / "Main.qml"
+ source.write_bytes(source.read_bytes().replace(b"output: input", b"output: input + 2"))
+ original_export = export.to_json
+ seen = []
+
+ def corrupt_direction(graph, communities, output_path, **kwargs):
+ # This seam receives the actual extraction/build result. The real JSON
+ # writer below owns validation, the rejection result and durable writes.
+ read = next(identity for identity, data in graph.nodes(data=True) if qml_metadata(data).get("kind") == "read")
+ target = next(identity for identity, data in graph.nodes(data=True) if qml_metadata(data).get("kind") == "property"
+ and qml_metadata(data).get("raw_name") == "input")
+ edge = graph.edges[read, target]
+ if corruption == "foreign":
+ edge["_src"] = "private-needle\nforged diagnostic"
+ else:
+ edge.pop("_src")
+ result = original_export(graph, communities, output_path, **kwargs)
+ seen.append(result)
+ return result
+
+ with monkeypatch.context() as fault:
+ fault.setattr(export, "to_json", corrupt_direction)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ clustered(tmp_path, monkeypatch, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(tmp_path, changed_paths=[source])
+ assert seen == [False] and snapshot(output) == prior
+ assert all(path.read_bytes() == contents for path, contents in ast.items())
+ diagnostic = capsys.readouterr().err
+ assert "QT_EXPORT_DIRECTION" in diagnostic
+ assert "private-needle" not in diagnostic and "forged diagnostic" not in diagnostic
+ assert not (output / ".graph.tmp.json").exists() and not list(output.glob(".gfy-publish-*"))
+
+ repaired = clustered(tmp_path, monkeypatch, operation, [source])
+ assert source_contract(repaired) == source_contract(clean(tmp_path, tmp_path / ".clean-cache"))
+ assert source_contract(repaired, only_python=True) == kept and source_contract(repaired) != source_contract(initial)
+ read = next(identity for identity, data in repaired.nodes(data=True) if qml_metadata(data).get("kind") == "read")
+ target = next(identity for identity, data in repaired.nodes(data=True) if qml_metadata(data).get("kind") == "property"
+ and qml_metadata(data).get("raw_name") == "input")
+ assert read in {hit.node_id for hit in affected_nodes(repaired, target, depth=1)}
+ current = snapshot(output)
+ assert normalized(clustered(tmp_path, monkeypatch, operation, [])) == normalized(repaired)
+ assert snapshot(output) == current
diff --git a/tests/test_qt_loader_boundaries.py b/tests/test_qt_loader_boundaries.py
new file mode 100644
index 0000000000..db89452d93
--- /dev/null
+++ b/tests/test_qt_loader_boundaries.py
@@ -0,0 +1,174 @@
+"""Literal loader overloads must preserve source authority without Qt execution."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis, sites
+
+
+def corpus(tmp_path, body, declarations=""):
+ """A public resource URL avoids assuming the analyzed app's working directory."""
+ return analysis(tmp_path, {
+ "access.cpp": declarations + "\nvoid use() { " + body + " }",
+ "Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "ui.qrc": 'Main.qml',
+ })
+
+
+@pytest.mark.parametrize("setup,root", [
+ ('QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ('QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"), nullptr);', 'engine.rootObjects().first()'),
+ ('QQmlApplicationEngine engine(nullptr); engine.load(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ('QQmlEngine engine; QQmlComponent component(&engine, nullptr); component.loadUrl(QUrl("qrc:/ui/Main.qml"));', 'component.create()'),
+ ('QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"), QQmlComponent::Asynchronous);', 'component.create()'),
+ ('QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml"), QQmlComponent::PreferSynchronous);', 'component.create()'),
+ ('QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml"));', 'view.rootObject()'),
+ ('QQmlApplicationEngine engine(":/ui/Main.qml");', 'engine.rootObjects().first()'),
+])
+def test_req_qml017_ac01_supported_loader_overloads_have_one_source(tmp_path, setup, root):
+ """Parent-only construction supplies identity, never a false second load."""
+ result = corpus(tmp_path, setup + ' auto root = ' + root + '; root->property("value");')
+ loads = sites(result, "qml_load")
+ assert len(loads) == 1 and qt_metadata(loads[0])["status"] == "resolved"
+ member = sites(result, "qml_access", "property")[0]
+ assert qt_metadata(member)["status"] == "resolved"
+ assert any(edge["source"] == member["id"] and edge["target"] == qt_metadata(member)["target_id"]
+ for edge in result["edges"])
+
+
+@pytest.mark.parametrize("expression", [
+ 'QUrl::fromLocalFile("qrc:/ui/Main.qml")',
+ 'QUrl::fromLocalFile("file:///ui/Main.qml")',
+ 'QUrl::fromLocalFile("Main.qml")',
+ 'QUrl(QUrl::fromLocalFile("qrc:/ui/Main.qml"))',
+ 'QUrl(":/ui/Main.qml")',
+ 'QUrl(prefix + suffix)',
+])
+@pytest.mark.parametrize("route", ["engine_constructor", "component_url"])
+def test_req_qml017_ac01_local_file_wrapper_cannot_become_resource_url(tmp_path, expression, route):
+ """Wrapper semantics and dynamic/local-base uncertainty cannot lend qrc facts."""
+ setup = (f'QQmlApplicationEngine engine({expression}); auto root = engine.rootObjects().first();'
+ if route == "engine_constructor" else
+ f'QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl({expression}); auto root = component.create();')
+ result = corpus(tmp_path, setup + ' root->property("value");')
+ loads = sites(result, "qml_load")
+ assert len(loads) == 1 and qt_metadata(loads[0])["status"] != "resolved"
+ assert not qt_metadata(loads[0]).get("target_id")
+ assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id")
+
+
+def test_req_qml017_ac01_absolute_fromlocalfile_retains_file_component(tmp_path):
+ """An absolute accepted path is converted to a file URI, with exact QML target."""
+ path = str(tmp_path / "Main.qml").replace("\\", "/")
+ result = corpus(tmp_path, f'QQmlApplicationEngine engine(QUrl::fromLocalFile("{path}")); '
+ 'auto root = engine.rootObjects().first(); root->property("value");')
+ assert qt_metadata(sites(result, "qml_load")[0])["literal_url"] == (tmp_path / "Main.qml").as_uri()
+ assert qt_metadata(sites(result, "qml_access", "property")[0])["status"] == "resolved"
+
+
+@pytest.mark.parametrize("declaration,local", [
+ ('namespace App { class QUrl { public: QUrl(const char*); };', ''),
+ ('namespace App { ::QUrl QUrl(const char*) { return ::QUrl("qrc:/ui/Other.qml"); }', ''),
+ ('namespace App {', 'auto QUrl = [](const char*) { return ::QUrl("qrc:/ui/Other.qml"); };'),
+])
+def test_req_qml017_ac01_url_wrapper_shadow_cannot_lend_literal_argument(tmp_path, declaration, local):
+ """A custom type/callable can construct a different value than its argument."""
+ result = analysis(tmp_path, {
+ "access.cpp": declaration + ' void use() { ' + local + ' QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml")); '
+ 'auto root = engine.rootObjects().first(); root->property("value"); } }',
+ "Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "ui.qrc": 'Main.qml',
+ })
+ assert not any(qt_metadata(node).get("target_id") for node in sites(result, "qml_load"))
+ assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id")
+
+
+def test_req_qml017_ac01_custom_component_engine_cannot_authorize_constructor(tmp_path):
+ """A known local declaration named QQmlEngine does not prove SDK ownership."""
+ result = corpus(tmp_path, 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml")); '
+ 'auto root = component.create(); root->property("value");', 'class QQmlEngine {};')
+ assert not any(qt_metadata(node).get("target_id") for node in sites(result, "qml_load"))
+ assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id")
+
+
+def test_req_qml017_ac01_global_sdk_constructor_bypasses_namespace_shadow(tmp_path):
+ """An explicit global SDK type retains authority beneath an unrelated name."""
+ result = analysis(tmp_path, {
+ "access.cpp": 'namespace App { class QQmlApplicationEngine {}; void use() { '
+ '::QQmlApplicationEngine engine(::QUrl("qrc:/ui/Main.qml")); '
+ 'auto root = engine.rootObjects().first(); root->property("value"); } }',
+ "Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "ui.qrc": 'Main.qml',
+ })
+ assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved"
+ assert qt_metadata(sites(result, "qml_access", "property")[0])["status"] == "resolved"
+
+
+@pytest.mark.parametrize("declarations,kind", [
+ ("namespace Other { class QQmlApplicationEngine {}; }", "Other::QQmlApplicationEngine"),
+ ("class QQmlApplicationEngine {};", "QQmlApplicationEngine"),
+ ("class Fake {}; using QQmlApplicationEngine = Fake;", "QQmlApplicationEngine"),
+ ("namespace Other { class QQmlComponent {}; }", "Other::QQmlComponent"),
+])
+def test_req_qml017_ac01_sdk_name_shadow_cannot_authorize_loader(tmp_path, declarations, kind):
+ """A qualified custom type or a local SDK-name declaration is not a Qt loader."""
+ component = "Component" in kind
+ setup = (f'QQmlEngine engine; {kind} component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml")); auto root = component.create();'
+ if component else
+ f'{kind} engine(QUrl("qrc:/ui/Main.qml")); engine.load(QUrl("qrc:/ui/Main.qml")); auto root = engine.rootObjects().first();')
+ result = corpus(tmp_path, setup + ' root->property("value");', declarations)
+ assert not any(qt_metadata(node).get("target_id") for node in sites(result, "qml_load"))
+ assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id")
+
+
+@pytest.mark.parametrize("load,create", [
+ ('component.loadUrl(QUrl("qrc:/ui/Main.qml"), computedMode)', 'component.create()'),
+ ('component.loadUrl(QUrl("qrc:/ui/Main.qml"), QQmlComponent::Asynchronous, nullptr)', 'component.create()'),
+ ('component.loadUrl(QUrl("qrc:/ui/Main.qml"))', 'component.create(otherContext)'),
+ ('component.loadUrl(QUrl("qrc:/ui/Main.qml")); component.loadUrl(QUrl("qrc:/ui/Main.qml"))', 'component.create()'),
+ ('if (enabled) component.loadUrl(QUrl("qrc:/ui/Main.qml"))', 'component.create()'),
+ ('component.loadUrl(QUrl("qrc:/ui/Missing.qml"))', 'component.create()'),
+])
+def test_req_qml017_ac04_unknown_overload_or_creation_cannot_prove_root(tmp_path, load, create):
+ """Mode, context, conditional, repeated and unavailable loads keep no root target."""
+ result = corpus(tmp_path, 'QQmlEngine engine; QQmlComponent component(&engine); '
+ + load + '; auto root = ' + create + '; root->property("value");')
+ access = sites(result, "qml_access", "property")[0]
+ assert qt_metadata(access)["status"] != "resolved" and not qt_metadata(access).get("target_id")
+
+
+@pytest.mark.parametrize("mode", ["Asynchronous", "PreferSynchronous"])
+@pytest.mark.parametrize("parent", ["", ", nullptr"])
+def test_req_qml017_ac01_constructor_mode_needs_sdk_type_authority(tmp_path, mode, parent):
+ """A global SDK receiver cannot lend authority to a shadowed enum owner."""
+ result = corpus(tmp_path, 'QQmlEngine engine; using QQmlComponent = Fake; '
+ f'::QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"), QQmlComponent::{mode}{parent}); '
+ 'auto root = component.create(); root->property("value");',
+ 'struct Fake { enum CompilationMode { Asynchronous, PreferSynchronous }; };')
+ assert qt_metadata(sites(result, "qml_load")[0])["loader_supported"] is False
+ assert not qt_metadata(sites(result, "qml_access", "property")[0]).get("target_id")
+
+
+@pytest.mark.parametrize("wrapper", ["QString", "QLatin1String", "QByteArray"])
+@pytest.mark.parametrize("shadow", ["alias", "callable", "global_sdk"])
+def test_req_qml017_ac01_string_wrapper_requires_source_authority(tmp_path, wrapper, shadow):
+ """Written wrapper arguments cannot prove a value returned by custom code."""
+ declaration = (f'using {wrapper} = Fake;' if shadow == "alias" else
+ f'::{wrapper} {wrapper}(const char*) {{ return ::{wrapper}("qrc:/ui/Other.qml"); }}'
+ if shadow == "callable" else f'class {wrapper} {{}};')
+ spelling = "::" + wrapper if shadow == "global_sdk" else wrapper
+ result = analysis(tmp_path, {
+ "access.cpp": 'class Fake {}; namespace Public { ' + declaration + ' void use() { '
+ f'QQmlApplicationEngine engine({spelling}("qrc:/ui/Main.qml")); '
+ 'auto root = engine.rootObjects().first(); root->property("value"); } }',
+ "Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "Other.qml": "import QtQml\nQtObject { property int value: 2 }",
+ "ui.qrc": 'Main.qmlOther.qml',
+ })
+ load = qt_metadata(sites(result, "qml_load")[0])
+ access = qt_metadata(sites(result, "qml_access", "property")[0])
+ if shadow == "global_sdk":
+ assert load["status"] == access["status"] == "resolved"
+ else:
+ assert not load.get("target_id") and not access.get("target_id")
diff --git a/tests/test_qt_loader_header_shadows.py b/tests/test_qt_loader_header_shadows.py
new file mode 100644
index 0000000000..7a3d273ba4
--- /dev/null
+++ b/tests/test_qt_loader_header_shadows.py
@@ -0,0 +1,203 @@
+"""INC-QML-24: an accepted included forward class is not external SDK proof."""
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.extractors.qt_cpp_syntax import read_cpp
+from graphify.extractors.qt_cpp_type_aliases import IncludedAliasShadows
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+from tests.test_qt_semantic_correction_updates import products
+
+
+FORMS = [
+ ("QQmlApplicationEngine", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ("QQmlApplicationEngine", 'QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'),
+ ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml"));', 'component.create()'),
+ ("QQmlEngine", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'),
+ ("QQuickView", 'QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml"));', 'view.rootObject()'),
+ ("QUrl", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ("QString", 'QQmlApplicationEngine engine(QString("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+]
+
+
+def fixture(root, form=FORMS[1], *, header="", include=True):
+ _, load, handle = form
+ sources = {
+ "shadow.h": header,
+ "access.cpp": ('#include "shadow.h"\n' if include else '') +
+ 'void use() { ' + load + f' auto root={handle}; root->property("value"); }}',
+ "Main.qml": 'import QtQml\nQtObject { property int value: 1 }',
+ "ui.qrc": 'Main.qml',
+ "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n',
+ }
+ return analysis(root, sources)
+
+
+@pytest.mark.parametrize("form", FORMS)
+@pytest.mark.parametrize("shadow", [False, True])
+def test_req_qml017_ac01_included_forward_class_blocks_sdk_loader_and_wrapper_authority(tmp_path, form, shadow):
+ sdk, _, _ = form
+ result = fixture(tmp_path, form, header=f"class {sdk};" if shadow else "// no source type shadow")
+ access = sites(result, "qml_access", "property")[0]
+ metadata = qt_metadata(access)
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ output = tmp_path / "proof.json"
+ assert to_json(graph, {}, output, force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ restored = load_node_link_graph(payload)
+ span = metadata["span"]
+ assert (tmp_path / "access.cpp").read_bytes()[span["start_byte"]:span["end_byte"]] == b'root->property("value")'
+ links = [edge for edge in payload["links"] if edge["source"] == access["id"]
+ and edge.get("context") == "qt_cpp_qml_property_read"]
+ if shadow:
+ assert not metadata.get("target_id") and not links
+ # The generic source forward survives even though no SDK/API target is supplied.
+ assert any(node.get("metadata", {}).get("cpp_class", {}).get("is_definition") is False
+ and node["source_file"] == "shadow.h" for node in result["nodes"])
+ else:
+ assert metadata["status"] == "resolved" and len(links) == 1
+ assert restored.has_edge(access["id"], metadata["target_id"])
+ assert not restored.has_edge(metadata["target_id"], access["id"])
+
+
+@pytest.mark.parametrize("header,include", [
+ ("class QQmlApplicationEngine;", False),
+ ("namespace Other { class QQmlApplicationEngine; }", True),
+])
+def test_req_qml017_ac01_unincluded_and_other_namespace_forwards_cannot_retarget_sdk(tmp_path, header, include):
+ result = fixture(tmp_path, header=header, include=include)
+ access = sites(result, "qml_access", "property")[0]
+ assert qt_metadata(access)["status"] == "resolved" and qt_metadata(access)["target_id"]
+
+
+@pytest.mark.parametrize("corruption", ["transport", "span", "line", "origin", "absolute", "outside"])
+def test_req_qml017_ac04_included_class_shadow_rejects_corrupt_source_provenance(tmp_path, corruption):
+ result = fixture(tmp_path, header="class QQmlApplicationEngine;")
+ shadow = copy.deepcopy(next(node for node in result["nodes"]
+ if node["source_file"] == "shadow.h" and node.get("metadata", {}).get("cpp_class")))
+ fact = shadow["metadata"]["cpp_class"]
+ if corruption == "transport":
+ fact["qualified_name_b64"] = "invalid!"
+ elif corruption == "span":
+ fact["span"]["start_row"] = True
+ elif corruption == "line":
+ shadow["source_location"] = "L99"
+ elif corruption == "origin":
+ shadow["_origin"] = "semantic"
+ else:
+ shadow["source_file"] = "/outside/shadow.h" if corruption == "absolute" else "../shadow.h"
+ before = copy.deepcopy(shadow)
+ with pytest.raises(ValueError, match="QT_METADATA"):
+ IncludedAliasShadows(read_cpp(tmp_path / "access.cpp", tmp_path), [shadow])
+ assert shadow == before
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_header_only_forward_edits_match_cold_warm_and_restore(tmp_path, monkeypatch, operation):
+ fixture(tmp_path, header="// no source shadow")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ source = tmp_path / "shadow.h"
+ original_cpp = (tmp_path / "access.cpp").read_bytes()
+ initial_targets = [qt_metadata(data).get("target_id") for _, data in initial.nodes(data=True)
+ if qt_metadata(data).get("kind") == "qml_access"]
+ assert len(initial_targets) == 1 and initial_targets[0]
+ source.write_text("class QQmlApplicationEngine;", encoding="utf-8")
+ rejected = run(tmp_path, monkeypatch, operation, [source])
+ assert all(not qt_metadata(data).get("target_id") for _, data in rejected.nodes(data=True)
+ if qt_metadata(data).get("kind") == "qml_access")
+ assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".rejected"))
+ assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".rejected"))
+ assert unrelated(rejected) == unrelated(initial)
+ source.write_text("// no source shadow", encoding="utf-8")
+ restored = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(restored) == normalized(initial) == normalized(clean(tmp_path, tmp_path / ".restored"))
+ assert (tmp_path / "access.cpp").read_bytes() == original_cpp
+ before_repeat = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(restored)
+ assert products(tmp_path) == before_repeat
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("fault", ["parse", "publication"])
+def test_req_qml017_ac04_header_shadow_failure_preserves_products_and_retries(tmp_path, monkeypatch, operation, fault):
+ import graphify.watch as watch
+
+ fixture(tmp_path, header="// no source shadow")
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ source = tmp_path / "shadow.h"
+ before = products(tmp_path)
+ source.write_text("class QQmlApplicationEngine;", encoding="utf-8")
+ replace = watch.os_replace_with_fallback
+
+ def fail_graph(start, destination):
+ if destination.name == "graph.json":
+ raise PermissionError("public header-shadow publication failure")
+ return replace(start, destination)
+
+ with monkeypatch.context() as failure:
+ if fault == "parse":
+ source.write_text("class QQmlApplicationEngine; void broken( {", encoding="utf-8")
+ else:
+ failure.setattr(watch, "os_replace_with_fallback", fail_graph)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failure, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert products(tmp_path) == before
+ source.write_text("class QQmlApplicationEngine;", encoding="utf-8")
+ recovered = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".recovered")) != normalized(initial)
+ assert all(not qt_metadata(data).get("target_id") for _, data in recovered.nodes(data=True)
+ if qt_metadata(data).get("kind") == "qml_access")
+ current = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered)
+ assert products(tmp_path) == current
+
+
+@pytest.mark.parametrize("visible", [False, True])
+def test_req_qml017_ac01_transitive_header_shadow_requires_prior_literal_include(tmp_path, visible):
+ form = FORMS[1]
+ fixture(tmp_path, form, header="class QQmlApplicationEngine;")
+ access = (tmp_path / "access.cpp").read_text(encoding="utf-8").replace('#include "shadow.h"\n', '')
+ include = '#include "bridge.h"\n'
+ result = analysis(tmp_path, {"access.cpp": include + access if visible else access + "\n" + include,
+ "bridge.h": '#include "shadow.h"\n',
+ "shadow.h": "class QQmlApplicationEngine;",
+ "Main.qml": 'import QtQml\nQtObject { property int value: 1 }',
+ "ui.qrc": 'Main.qml'})
+ metadata = qt_metadata(sites(result, "qml_access", "property")[0])
+ assert bool(metadata.get("target_id")) is not visible
+
+
+def test_req_qml017_ac04_class_shadow_walk_overflow_rejects_instead_of_discarding_header(tmp_path):
+ """No header is opened or guessed by lookup; accepted include paths own its bound."""
+ result = fixture(tmp_path, header="class QQmlApplicationEngine;")
+ shadow = copy.deepcopy(next(node for node in result["nodes"]
+ if node["source_file"] == "shadow.h" and node.get("metadata", {}).get("cpp_class")))
+ # Test the public dependency snapshot at its actual traversal seam. It is
+ # already accepted source metadata, not a fake resolution or parser result.
+ includes = []
+ for index in range(129):
+ name, target = ("shadow.h" if index == 0 else f"part{index}.h"), f"part{index + 1}.h"
+ includes.append({"id": f"include{index}", "label": "Qt type_include: " + target,
+ "source_file": name, "source_location": "L1-L1", "file_type": "code", "_origin": "ast",
+ "metadata": {"qt": {"contract_version": 1, "kind": "type_include", "raw_name": target,
+ "value": target, "span": {"start_byte": 0, "end_byte": 24,
+ "start_row": 0, "end_row": 0, "start_column": 0, "end_column": 24}}}})
+ index = IncludedAliasShadows(read_cpp(tmp_path / "access.cpp", tmp_path), [shadow, *includes])
+ position = (tmp_path / "access.cpp").read_bytes().index(b"engine.load")
+ from graphify.extractors.qt_cpp_syntax import QtCppError
+ with pytest.raises(QtCppError, match="128"):
+ index.class_declared((), "unrelated", position)
diff --git a/tests/test_qt_loader_provenance.py b/tests/test_qt_loader_provenance.py
new file mode 100644
index 0000000000..c60ba448d7
--- /dev/null
+++ b/tests/test_qt_loader_provenance.py
@@ -0,0 +1,61 @@
+"""REQ-QML-017 literal loader admission through real facade/build/JSON provenance."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+
+
+@pytest.mark.parametrize("loader", ["engine_load", "engine_url_constructor", "component_load_url"])
+def test_req_qml017_ac01_literal_loader_keeps_component_and_property_provenance(tmp_path, loader):
+ """Equivalent literal loaders must preserve exact source and persisted joins."""
+ url = (tmp_path / "Main.qml").as_uri()
+ loader_expressions = {
+ "engine_load": f'engine.load(QUrl("{url}"))',
+ "engine_url_constructor": f'QQmlApplicationEngine engine(QUrl("{url}"))',
+ "component_load_url": f'component.loadUrl(QUrl("{url}"))',
+ }
+ expression = loader_expressions[loader]
+ forms = {
+ "engine_load": f'QQmlApplicationEngine engine; {expression}; auto root = engine.rootObjects().first();',
+ "engine_url_constructor": f'{expression}; auto root = engine.rootObjects().first();',
+ "component_load_url": f'QQmlEngine engine; QQmlComponent component(&engine); {expression}; auto root = component.create();',
+ }
+ source = 'void use() { ' + forms[loader] + ' root->property("value"); }'
+ result = analysis(tmp_path, {
+ "access.cpp": source,
+ "Main.qml": 'import QtQml\nQtObject { property int value: 1 }',
+ })
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+
+ # Check the source-owning loader occurrence, not a URL-less constructor or
+ # a generic C++ call that lacks special component provenance.
+ original = source.encode()
+ loaders = [node for node in sites(result, "qml_load")
+ if original[qt_metadata(node)["span"]["start_byte"]:
+ qt_metadata(node)["span"]["end_byte"]] == expression.encode()]
+ assert len(loaders) == 1, {
+ "loader": loader,
+ "recognized_operations": [qt_metadata(node)["operation"] for node in sites(result, "qml_load")],
+ }
+ metadata = qt_metadata(loaders[0])
+ assert metadata["status"] == "resolved"
+ assert metadata["target_id"] in reloaded
+ assert reloaded.has_edge(loaders[0]["id"], metadata["target_id"])
+ assert reloaded.edges[loaders[0]["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_load"
+
+ access = sites(result, "qml_access", "property")[0]
+ metadata = qt_metadata(access)
+ assert metadata["status"] == "resolved"
+ assert original[metadata["span"]["start_byte"]:metadata["span"]["end_byte"]] == b'root->property("value")'
+ assert reloaded.has_edge(access["id"], metadata["target_id"])
+ assert reloaded.edges[access["id"], metadata["target_id"]]["context"] == "qt_cpp_qml_property_read"
diff --git a/tests/test_qt_loader_providers.py b/tests/test_qt_loader_providers.py
new file mode 100644
index 0000000000..36f2e5633a
--- /dev/null
+++ b/tests/test_qt_loader_providers.py
@@ -0,0 +1,97 @@
+"""REQ-QML-017-AC01/AC03: component loaders retain exact engine providers."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_loader_updates import assert_context
+
+BACKEND = '''class Backend : public QObject { Q_OBJECT Q_PROPERTY(int count READ count)
+public: int count() { return 1; } };
+'''
+LOAD = 'component.loadUrl(QUrl("qrc:/ui/Main.qml")); auto root = component.create();'
+
+
+def published(root, source, *, bare=False, directed=False):
+ """The producer, resolver, strict build guard and actual JSON writer all run."""
+ result = analysis(root, {
+ "backend.h": BACKEND,
+ "access.cpp": '#include "backend.h"\n' + source,
+ "Main.qml": 'import QtQml\nQtObject { property int value: ' + ("count" if bare else "backend.count") + ' }',
+ "ui.qrc": 'Main.qml',
+ "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h access.cpp)\n',
+ })
+ graph = build_from_json(result, root=root, directed=directed)
+ output = root / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ return result, load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+
+
+@pytest.mark.parametrize("engine_type", ["QQmlEngine", "QQmlApplicationEngine"])
+@pytest.mark.parametrize("context_object", [False, True])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml017_ac03_component_load_url_uses_its_exact_engine_provider(tmp_path, engine_type, context_object, directed):
+ """Engine-only construction associates one later URL load with its provider."""
+ exposure = 'setContextObject(backend)' if context_object else 'setContextProperty("backend", backend)'
+ source = f'''void use(Backend *backend) {{ {engine_type} engine;
+ engine.rootContext()->{exposure}; QQmlComponent component(&engine); {LOAD} }}'''
+ result, graph = published(tmp_path, source, bare=context_object, directed=directed)
+ loads = sites(result, "qml_load")
+ assert len(loads) == 1 and qt_metadata(loads[0])["operation"] == "loadUrl"
+ loader = qt_metadata(loads[0])
+ expose = qt_metadata(sites(result, "context_exposure")[0])
+ binding = sites(result, "context_binding")[0]
+ bound = qt_metadata(binding)
+ assert loader["status"] == bound["status"] == "resolved"
+ assert loader["engine_declaration_id"] == expose["engine_declaration_id"] == bound["engine_declaration_id"]
+ assert len(bound["engine_declaration_id"]) == 64
+ assert bound["provider_declaration_id"] == expose["provider_declaration_id"]
+ assert bound["component_target_id"] == loader["target_id"]
+ assert_context(graph, loads[0]["id"], loader["target_id"], "qt_cpp_qml_load")
+ assert_context(graph, binding["id"], bound["component_target_id"], "qt_context_exposure")
+ access = sites(result, "context_access")[0]
+ target = qt_metadata(access)["target_id"]
+ assert graph.nodes[target]["source_file"] == "backend.h"
+ assert_context(graph, access["id"], target, "qt_context_member")
+ if directed:
+ assert not graph.has_edge(target, access["id"])
+
+
+@pytest.mark.parametrize("body", [
+ '{ QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); } '
+ '{ QQmlEngine engine; QQmlComponent component(&engine); ' + LOAD + ' }',
+ 'QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); '
+ '{ QQmlEngine engine; QQmlComponent component(&engine); ' + LOAD + ' }',
+])
+def test_req_qml017_ac03_component_engine_shadow_cannot_borrow_another_provider(tmp_path, body):
+ """A valid component load must not select an exposure by engine spelling."""
+ result, graph = published(tmp_path, 'void use(Backend *backend) { ' + body + ' }')
+ loads = sites(result, "qml_load")
+ assert len(loads) == 1 and qt_metadata(loads[0])["status"] == "resolved"
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+ assert not [edge for _, _, edge in graph.edges(data=True)
+ if edge.get("context") in {"qt_context_exposure", "qt_context_member"}]
+
+
+@pytest.mark.parametrize("body", [
+ 'QQmlEngine *engine = new QQmlEngine; engine->rootContext()->setContextProperty("backend", backend); '
+ 'engine = replacement; QQmlComponent component(engine); ' + LOAD,
+ 'QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); '
+ 'if (enabled) { QQmlComponent component(&engine); ' + LOAD + ' }',
+ 'QQmlEngine engine; engine.rootContext()->setContextProperty("backend", backend); '
+ 'QQmlComponent component(factory()); ' + LOAD,
+])
+def test_req_qml017_ac03_component_engine_assignment_condition_and_factory_reject(tmp_path, body):
+ """Unproved constructor engine associations publish no component/provider targets."""
+ source = 'void use(Backend *backend, QQmlEngine *replacement) { ' + body + ' }'
+ result, graph = published(tmp_path, source)
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+ assert not [md for node in sites(result, "qml_load") if (md := qt_metadata(node))["status"] == "resolved"]
+ assert not [edge for _, _, edge in graph.edges(data=True)
+ if edge.get("context") in {"qt_cpp_qml_load", "qt_context_exposure", "qt_context_member"}]
diff --git a/tests/test_qt_loader_sdk_declarations.py b/tests/test_qt_loader_sdk_declarations.py
new file mode 100644
index 0000000000..457bfe9a2a
--- /dev/null
+++ b/tests/test_qt_loader_sdk_declarations.py
@@ -0,0 +1,136 @@
+"""Loader SDK authority cannot come from an incomplete source declaration."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+from tests.test_qt_loader_updates import assert_load, facts, fixture, products, source
+
+
+@pytest.mark.parametrize("sdk_name,load,root", [
+ ("QQmlApplicationEngine", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ("QQmlApplicationEngine", 'QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'),
+ ("QQmlComponent", 'QQmlEngine engine; QQmlComponent component(&engine); component.loadUrl(QUrl("qrc:/ui/Main.qml"));', 'component.create()'),
+ ("QQmlEngine", 'QQmlEngine engine; QQmlComponent component(&engine, QUrl("qrc:/ui/Main.qml"));', 'component.create()'),
+ ("QQuickView", 'QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml"));', 'view.rootObject()'),
+ ("QUrl", 'QQmlApplicationEngine engine(QUrl("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+ ("QString", 'QQmlApplicationEngine engine(QString("qrc:/ui/Main.qml"));', 'engine.rootObjects().first()'),
+])
+@pytest.mark.parametrize("shadow", [False, True])
+def test_req_qml017_ac01_incomplete_sdk_declaration_cannot_authorize_loader(tmp_path, sdk_name, load, root, shadow):
+ """Paired external SDK controls and unknown source classes reach JSON reload."""
+ source = (f'class {sdk_name};\n' if shadow else '') + 'void use() { ' + load + f' auto root={root}; root->property("value"); }}'
+ result = analysis(tmp_path, {
+ 'access.cpp': source,
+ 'Main.qml': 'import QtQml\nQtObject { property int value: 1 }',
+ 'ui.qrc': 'Main.qml',
+ })
+ access = sites(result, 'qml_access', 'property')[0]
+ graph = build_from_json(result)
+ output = tmp_path / 'graph.json'
+ to_json(graph, {}, output)
+ restored = load_node_link_graph(json.loads(output.read_text(encoding='utf-8')))
+ target = qt_metadata(access).get('target_id')
+ if shadow:
+ assert not target
+ assert not [data for _, _, data in restored.edges(data=True)
+ if data.get('_src') == access['id'] and data.get('context') == 'qt_cpp_qml_property_read']
+ else:
+ assert qt_metadata(access)['status'] == 'resolved' and target in restored
+
+
+@pytest.mark.parametrize('prefix', ['if (ready) ', 'engine = factory(); '])
+def test_req_qml017_ac04_uncertain_sdk_use_retains_observed_loader(tmp_path, prefix):
+ """Static SDK type authority does not establish conditional/reassigned identity."""
+ result = analysis(tmp_path, {
+ 'access.cpp': 'void use() { QQmlApplicationEngine engine; ' + prefix
+ + 'engine.load(QUrl("qrc:/ui/Main.qml")); }',
+ 'Main.qml': 'import QtQml\nQtObject {}',
+ 'ui.qrc': 'Main.qml',
+ })
+ loads = sites(result, 'qml_load')
+ assert len(loads) == 1
+ metadata = qt_metadata(loads[0])
+ assert metadata['status'] == 'dynamic' and not metadata.get('target_id')
+
+
+@pytest.mark.parametrize('operation', ['manual', 'watch'])
+@pytest.mark.parametrize('form,sdk_name', [('engine_constructor', 'QQmlApplicationEngine'),
+ ('component_load_url', 'QQmlComponent')])
+def test_req_qml017_ac04_loader_forward_edits_retire_and_restore_targets(tmp_path, monkeypatch, operation, form, sdk_name):
+ """Real SDK→forward→SDK→removed updates agree with complete fresh graphs."""
+ cpp, expression = fixture(tmp_path, form)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv('GRAPHIFY_NO_TIPS', '1')
+ original = cpp.read_bytes()
+ initial = run(tmp_path, monkeypatch, operation)
+ assert_load(initial, original, expression)
+ retained = unrelated(initial)
+ assert normalized(initial) == normalized(clean(tmp_path, tmp_path / '.cold'))
+ cpp.write_bytes(f'class {sdk_name};\n'.encode() + original)
+ rejected = run(tmp_path, monkeypatch, operation, [cpp])
+ assert all(not md.get('target_id') for md in facts(rejected, 'qml_access').values())
+ assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / '.rejected'))
+ assert unrelated(rejected) == retained
+ cpp.write_bytes(original)
+ restored = run(tmp_path, monkeypatch, operation, [cpp])
+ assert normalized(restored) == normalized(initial)
+ assert normalized(restored) == normalized(clean(tmp_path, tmp_path / '.restored'))
+ before_repeat = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [cpp])) == normalized(restored)
+ assert products(tmp_path) == before_repeat
+ cpp.write_bytes(source(form, removed=True)[0].encode())
+ removed = run(tmp_path, monkeypatch, operation, [cpp])
+ assert all(not md.get('target_id') for md in facts(removed, 'qml_access').values())
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / '.removed'))
+ assert unrelated(removed) == retained
+
+
+@pytest.mark.parametrize('operation', ['manual', 'watch'])
+@pytest.mark.parametrize('fault', ['parse', 'publication'])
+def test_req_qml017_ac04_loader_forward_failure_retains_products_and_recovers(tmp_path, monkeypatch, operation, fault):
+ """Authority edits cannot advance accepted products through a failed update."""
+ import graphify.watch as watch
+ cpp, _ = fixture(tmp_path, 'engine_constructor')
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv('GRAPHIFY_NO_TIPS', '1')
+ original = cpp.read_bytes()
+ initial = run(tmp_path, monkeypatch, operation)
+ accepted = products(tmp_path)
+ rejected_source = b'class QQmlApplicationEngine;\n' + original
+ cpp.write_bytes(rejected_source)
+ real_replace = watch.os_replace_with_fallback
+
+ def fail_replace(start, destination):
+ if str(destination).endswith('graph.json'):
+ raise PermissionError('injected authority publication failure')
+ return real_replace(start, destination)
+
+ with monkeypatch.context() as failure:
+ if fault == 'parse':
+ cpp.write_bytes(rejected_source + b'void broken( {')
+ else:
+ failure.setattr(watch, 'os_replace_with_fallback', fail_replace)
+ if operation == 'manual':
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failure, operation, [cpp])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True)
+ assert products(tmp_path) == accepted
+ cpp.write_bytes(rejected_source)
+ recovered = run(tmp_path, monkeypatch, operation, [cpp])
+ assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / '.recovered'))
+ assert normalized(recovered) != normalized(initial)
+ assert all(not md.get('target_id') for md in facts(recovered, 'qml_access').values())
+ repeated = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered)
+ assert products(tmp_path) == repeated
diff --git a/tests/test_qt_loader_updates.py b/tests/test_qt_loader_updates.py
new file mode 100644
index 0000000000..41671e8f4b
--- /dev/null
+++ b/tests/test_qt_loader_updates.py
@@ -0,0 +1,234 @@
+"""REQ-QML-017-AC01/AC04: literal loader provenance and durable refresh."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.affected import affected_nodes, load_graph
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_consumer_helpers import cli as consumer_cli
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+
+def source(form, target="Main.qml", *, removed=False):
+ """Only the admitted static loader forms vary; no Qt runtime is invoked."""
+ url = f'QUrl("qrc:/ui/{target}")'
+ if form == "engine_constructor":
+ load = f'QQmlApplicationEngine engine({url})'
+ body = 'QQmlApplicationEngine engine' if removed else load
+ body += '; auto root = engine.rootObjects().first();'
+ else:
+ load = f'component.loadUrl({url})'
+ body = 'QQmlEngine engine; QQmlComponent component(&engine); '
+ body += '' if removed else load + '; '
+ body += 'auto root = component.create();'
+ return 'void use() { ' + body + ' root->property("value"); }\n', load
+
+
+def fixture(root, form):
+ content, expression = source(form)
+ files = {
+ "loader.cpp": content,
+ "Main.qml": 'import QtQml\nQtObject { property int value: 1 }\n',
+ "Alt.qml": 'import QtQml\nQtObject { property int value: 2 }\n',
+ "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml Alt.qml SOURCES loader.cpp)\n',
+ "ui.qrc": 'Main.qmlAlt.qml',
+ "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n',
+ }
+ for name, text in files.items():
+ (root / name).write_bytes(text.encode())
+ return root / "loader.cpp", expression
+
+
+def facts(graph, kind):
+ return {identity: qt_metadata(data) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") == kind}
+
+
+def assert_context(graph, source, target, context):
+ """CLI/watch can retain parallel edges; inspect the exact logical mechanism."""
+ data = graph.get_edge_data(source, target)
+ assert data is not None
+ records = data.values() if graph.is_multigraph() else [data]
+ matches = [edge for edge in records if edge.get("context") == context]
+ assert len(matches) == 1
+ assert (matches[0].get("_src", source), matches[0].get("_tgt", target)) == (source, target)
+
+
+def assert_load(graph, original, expression, filename="Main.qml"):
+ """The URL-owning source occurrence reaches the exact accepted component."""
+ matches = [(identity, md) for identity, md in facts(graph, "qml_load").items()
+ if original[md["span"]["start_byte"]:md["span"]["end_byte"]] == expression.encode()]
+ assert len(matches) == 1, [(md.get("operation"), md.get("status")) for md in facts(graph, "qml_load").values()]
+ assert len(facts(graph, "qml_load")) == 1
+ identity, metadata = matches[0]
+ assert metadata["status"] == "resolved" and len(metadata["receiver_declaration_id"]) == 64
+ target = metadata["target_id"]
+ assert graph.nodes[target]["source_file"] == filename and graph.has_edge(identity, target)
+ assert_context(graph, identity, target, "qt_cpp_qml_load")
+ roots = facts(graph, "qml_root")
+ assert len(roots) == 1 and all(md["status"] == "resolved" for md in roots.values())
+ assert all(graph.has_edge(root, md["target_id"]) for root, md in roots.items())
+ access, md = next(iter(facts(graph, "qml_access").items()))
+ assert md["status"] == "resolved" and graph.nodes[md["target_id"]]["source_file"] == filename
+ assert graph.has_edge(access, md["target_id"])
+ assert_context(graph, access, md["target_id"], "qt_cpp_qml_property_read")
+
+
+def products(root):
+ output = root / "graphify-out"
+ return {name: (output / name).read_bytes() for name in
+ ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")}
+
+
+@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"])
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_loader_edits_match_cold_warm_manual_and_watch(tmp_path, monkeypatch, form, operation):
+ """URL/source removal and metadata/QML edits retire stale persisted joins."""
+ cpp, expression = fixture(tmp_path, form)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold")
+ assert_load(cold, cpp.read_bytes(), expression)
+ assert normalized(clean(tmp_path, tmp_path / ".cold")) == normalized(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ untouched = unrelated(initial)
+
+ content, expression = source(form, "Alt.qml")
+ cpp.write_bytes(content.encode())
+ moved = run(tmp_path, monkeypatch, operation, [cpp])
+ assert_load(moved, cpp.read_bytes(), expression, "Alt.qml")
+ assert normalized(moved) == normalized(clean(tmp_path, tmp_path / ".moved"))
+ load_ids, access_ids = set(facts(moved, "qml_load")), set(facts(moved, "qml_access"))
+ unchanged_cpp = cpp.read_bytes()
+
+ # A metadata-only alias change invalidates the unchanged native loader.
+ resource = tmp_path / "ui.qrc"
+ valid_resource = resource.read_bytes()
+ resource.write_bytes(valid_resource.replace(b'alias="Alt.qml"', b'alias="renamed.qml"'))
+ missing = run(tmp_path, monkeypatch, operation, [resource])
+ assert set(facts(missing, "qml_load")) == load_ids and set(facts(missing, "qml_access")) == access_ids
+ assert cpp.read_bytes() == unchanged_cpp
+ assert all(md["status"] != "resolved" and not md.get("target_id") for md in facts(missing, "qml_load").values())
+ assert all(md["status"] != "resolved" and not md.get("target_id") for md in facts(missing, "qml_access").values())
+ assert normalized(missing) == normalized(clean(tmp_path, tmp_path / ".missing"))
+ resource.write_bytes(valid_resource)
+ assert normalized(run(tmp_path, monkeypatch, operation, [resource])) == normalized(moved)
+
+ # The source loader/root survive a QML-only member deletion, but its access
+ # must lose the previous target and projected relationship.
+ qml = tmp_path / "Alt.qml"
+ qml.write_bytes(qml.read_bytes().replace(b"int value:", b"int replacement:"))
+ changed = run(tmp_path, monkeypatch, operation, [qml])
+ assert set(facts(changed, "qml_load")) == load_ids and set(facts(changed, "qml_access")) == access_ids
+ assert cpp.read_bytes() == unchanged_cpp
+ assert all(md["status"] == "resolved" for md in facts(changed, "qml_load").values())
+ for identity, md in facts(changed, "qml_access").items():
+ assert md["status"] != "resolved" and not md.get("target_id")
+ assert not any(data.get("_src", start) == identity and data.get("relation") == "uses"
+ for start, _, data in changed.edges(data=True))
+ assert normalized(changed) == normalized(clean(tmp_path, tmp_path / ".qml-edit"))
+ cpp.write_bytes(source(form, removed=True)[0].encode())
+ removed = run(tmp_path, monkeypatch, operation, [cpp])
+ assert not [md for md in facts(removed, "qml_load").values() if md["status"] == "resolved"]
+ assert all(md["status"] != "resolved" for md in facts(removed, "qml_access").values())
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed"))
+ assert unrelated(removed) == untouched
+ before = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed)
+ assert products(tmp_path) == before
+
+
+@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"])
+@pytest.mark.parametrize("operation", ["manual", "force", "watch"])
+def test_req_qml017_ac04_loader_parse_failure_preserves_products_and_recovers(tmp_path, monkeypatch, form, operation):
+ """Real malformed input is rejected before all four durable products change."""
+ cpp, expression = fixture(tmp_path, form)
+ monkeypatch.chdir(tmp_path)
+ mode = "manual" if operation == "force" else operation
+ initial = run(tmp_path, monkeypatch, mode)
+ assert_load(initial, cpp.read_bytes(), expression)
+ valid, accepted = cpp.read_bytes(), products(tmp_path)
+ cpp.write_bytes(valid + b"void broken({")
+ if operation == "watch":
+ from graphify.watch import _rebuild_code
+ assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True)
+ else:
+ with pytest.raises(SystemExit) as rejected:
+ if operation == "force":
+ from tests.test_qt_cpp_upgrade_invalidation import cli
+ cli(tmp_path, monkeypatch, "update", force=True)
+ else:
+ run(tmp_path, monkeypatch, mode, [cpp])
+ assert rejected.value.code == 1
+ assert products(tmp_path) == accepted
+ cpp.write_bytes(valid)
+ repaired = run(tmp_path, monkeypatch, mode, [cpp])
+ assert normalized(repaired) == normalized(initial)
+ # Repair updates source stat/provenance; the subsequent no-change operation
+ # must preserve that repaired durable state, not its historical timestamps.
+ repaired_products = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(initial)
+ assert products(tmp_path) == repaired_products
+
+
+@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"])
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_loader_write_failure_preserves_products_and_retries(tmp_path, monkeypatch, form, operation):
+ """The real graph replacement can fail after successful parser/resolver work."""
+ import graphify.watch as watch
+ cpp, expression = fixture(tmp_path, form)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert_load(initial, cpp.read_bytes(), expression)
+ accepted = products(tmp_path)
+ content, expression = source(form, "Alt.qml")
+ cpp.write_bytes(content.encode())
+ real_replace = watch.os_replace_with_fallback
+
+ def fail_replace(start, destination):
+ if str(destination).endswith("graph.json"):
+ raise PermissionError("injected loader graph replacement failure")
+ return real_replace(start, destination)
+
+ with monkeypatch.context() as failure:
+ failure.setattr(watch, "os_replace_with_fallback", fail_replace)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failure, operation, [cpp])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True)
+ assert products(tmp_path) == accepted
+ recovered = run(tmp_path, monkeypatch, operation, [cpp])
+ assert_load(recovered, cpp.read_bytes(), expression, "Alt.qml")
+ assert normalized(recovered) != normalized(initial)
+ assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".recovered"))
+ repeated = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered)
+ assert products(tmp_path) == repeated
+
+
+@pytest.mark.parametrize("form", ["engine_constructor", "component_load_url"])
+def test_req_qml017_ac04_loader_directed_reload_query_and_affected_keep_endpoints(tmp_path, monkeypatch, capsys, form):
+ """Actual persisted directed consumers retain the precise loader/member path."""
+ cpp, expression = fixture(tmp_path, form)
+ from graphify.extract import collect_files, extract
+ result = extract(collect_files(tmp_path, root=tmp_path), root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ output = tmp_path / "directed.json"
+ assert to_json(graph, {}, str(output), force=True)
+ loaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert_load(loaded, cpp.read_bytes(), expression)
+ identity, metadata = next(iter(facts(loaded, "qml_access").items()))
+ assert not loaded.has_edge(metadata["target_id"], identity)
+ assert "Main.qml" in consumer_cli(monkeypatch, capsys, output, "query", identity)
+ assert "loader.cpp" in consumer_cli(monkeypatch, capsys, output, "explain", identity)
+ hits = {hit.node_id for hit in affected_nodes(load_graph(output), metadata["target_id"], relations=["uses"], depth=1)}
+ assert identity in hits
diff --git a/tests/test_qt_mcp_consumers.py b/tests/test_qt_mcp_consumers.py
new file mode 100644
index 0000000000..251ea5fe1b
--- /dev/null
+++ b/tests/test_qt_mcp_consumers.py
@@ -0,0 +1,78 @@
+"""QML-013-AC04 actual in-process MCP HTTP calls agree with saved-graph CLI."""
+import pytest
+
+pytest.importorskip("mcp", reason="QML-013-AC04 requires the MCP extra")
+pytest.importorskip("starlette", reason="QML-013-AC04 requires the MCP HTTP dependency")
+
+from starlette.testclient import TestClient # noqa: E402
+from graphify.build import build_from_json # noqa: E402
+from graphify.export import to_json # noqa: E402
+from graphify.serve import _build_http_app # noqa: E402
+from tests.qt_analysis_helpers import analysis # noqa: E402
+from tests.qt_consumer_helpers import cli, saved_graph # noqa: E402
+from tests.test_qt_signals_slots import SOURCE # noqa: E402
+
+HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
+
+
+def initialize(client):
+ response = client.post("/mcp", headers=HEADERS, json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
+ "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "qt-acceptance", "version": "1"}}})
+ assert response.status_code == 200
+ headers = {**HEADERS, "mcp-session-id": response.headers.get("mcp-session-id")}
+ client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
+ return headers
+
+
+def tool(client, headers, name, arguments, rid):
+ response = client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "id": rid, "method": "tools/call",
+ "params": {"name": name, "arguments": arguments}})
+ assert response.status_code == 200
+ result = response.json()["result"]
+ assert not result.get("isError"), result
+ return result["content"][0]["text"].strip()
+
+
+def test_mcp_query_node_and_neighbors_match_cli_source_confidence(tmp_path, monkeypatch, capsys):
+ """An initialized HTTP session queries persisted scoped facts and evidence."""
+ path, _, _, prop, read = saved_graph(tmp_path)
+ query = cli(monkeypatch, capsys, path, "query", read["id"])
+ app = _build_http_app(str(path), json_response=True)
+ with TestClient(app, base_url="http://127.0.0.1") as client:
+ headers = initialize(client)
+ # The CLI defaults to depth two; protocol defaults stay independent.
+ mcp_query = tool(client, headers, "query_graph", {"question": read["id"], "depth": 2}, 2)
+ assert mcp_query == query
+ node = tool(client, headers, "get_node", {"node_id": prop["id"]}, 4)
+ assert "Main.qml" in node and prop["id"] in node
+ neighbors = tool(client, headers, "get_neighbors", {"node_id": read["id"]}, 5)
+ assert "INFERRED" in neighbors and "Main.qml:L" in neighbors
+
+
+def test_mcp_exact_id_path_matches_cli_and_retains_source_evidence(tmp_path, monkeypatch, capsys):
+ """Opaque canonical IDs select the intended endpoints in both interfaces."""
+ path, _, component, prop, _ = saved_graph(tmp_path)
+ route = cli(monkeypatch, capsys, path, "path", component["id"], prop["id"])
+ with TestClient(_build_http_app(str(path), json_response=True), base_url="http://127.0.0.1") as client:
+ headers = initialize(client)
+ mcp_path = tool(client, headers, "shortest_path", {"source": component["id"], "target": prop["id"]}, 2)
+ assert mcp_path == route
+ assert "Main.qml:L" in mcp_path and "EXTRACTED" in mcp_path
+
+
+def test_qml016_ac04_http_metadata_search_keeps_connection_reference_semantics(tmp_path, monkeypatch, capsys):
+ """Native Qt facts stay searchable and references cannot become delivery calls."""
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ graph = build_from_json(result, root=tmp_path)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path), force=True)
+ query = cli(monkeypatch, capsys, path, "query", "QueuedConnection")
+ endpoint = next(edge["source"] for edge in result["edges"] if edge.get("context") == "qt_connect_signal")
+ with TestClient(_build_http_app(str(path), json_response=True), base_url="http://127.0.0.1") as client:
+ headers = initialize(client)
+ mcp_query = tool(client, headers, "query_graph", {"question": "QueuedConnection", "depth": 2}, 2)
+ assert mcp_query == query and "Qt connect:" in mcp_query and "events.cpp:L" in mcp_query
+ references = tool(client, headers, "get_neighbors", {"node_id": endpoint, "relation_filter": "references"}, 3)
+ assert "[references] [INFERRED]" in references and "events.cpp:L" in references
+ calls = tool(client, headers, "get_neighbors", {"node_id": endpoint, "relation_filter": "calls"}, 4)
+ assert "-->" not in calls and "<--" not in calls
diff --git a/tests/test_qt_mcp_stdio.py b/tests/test_qt_mcp_stdio.py
new file mode 100644
index 0000000000..a34f4db10f
--- /dev/null
+++ b/tests/test_qt_mcp_stdio.py
@@ -0,0 +1,44 @@
+"""Actual stdio MCP session parity against production saved-graph CLI consumers."""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+import pytest
+
+pytest.importorskip("mcp", reason="QML-013-AC04 requires the optional MCP SDK")
+
+from mcp import ClientSession, StdioServerParameters # noqa: E402
+from mcp.client.stdio import stdio_client # noqa: E402
+
+from tests.qt_consumer_helpers import cli, saved_graph # noqa: E402
+
+
+def text_result(result):
+ assert not result.isError, result
+ return "\n".join(item.text for item in result.content if item.type == "text").strip()
+
+
+def test_stdio_query_path_and_source_node_agree_with_cli(tmp_path, monkeypatch, capsys):
+ path, _, component, prop, read = saved_graph(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ query = cli(monkeypatch, capsys, path, "query", read["id"])
+ route = cli(monkeypatch, capsys, path, "path", component["id"], prop["id"])
+ parameters = StdioServerParameters(command=sys.executable,
+ args=["-X", "utf8", "-m", "graphify.serve", "--graph", str(path)],
+ env={**os.environ, "PYTHONUTF8": "1"}, cwd=str(tmp_path))
+
+ async def exercise():
+ async with stdio_client(parameters) as (reader, writer):
+ async with ClientSession(reader, writer) as session:
+ await session.initialize()
+ tools = await session.list_tools()
+ assert {"query_graph", "shortest_path", "get_node"} <= {tool.name for tool in tools.tools}
+ actual_query = await session.call_tool("query_graph", {"question": read["id"], "depth": 2})
+ actual_route = await session.call_tool("shortest_path", {"source": component["id"], "target": prop["id"]})
+ actual_node = await session.call_tool("get_node", {"node_id": prop["id"]})
+ assert text_result(actual_query) == query
+ assert text_result(actual_route) == route
+ assert "Main.qml" in text_result(actual_node) and prop["id"] in text_result(actual_node)
+ asyncio.run(asyncio.wait_for(exercise(), timeout=25))
diff --git a/tests/test_qt_member_source_links.py b/tests/test_qt_member_source_links.py
new file mode 100644
index 0000000000..cd0a16feb4
--- /dev/null
+++ b/tests/test_qt_member_source_links.py
@@ -0,0 +1,183 @@
+"""REQ-QML-008-AC02 links source occurrences without inventing class authority."""
+import copy
+import json
+import re
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.cluster import cluster
+from graphify.export import to_html, to_json
+from graphify.extractors.qt_cpp_exposure import enrich_qt_cpp, is_qt_cpp_source
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_qml_bridge import build_qt_qml_bridge
+from tests.qt_analysis_helpers import analysis, sites
+
+
+HEADER = "class Utility { public: void write(int value); };\n"
+METHOD = "void Utility::write(int value) { Q_UNUSED(value) }"
+LOCAL = "void collect(int value) { struct Row { int entry; }; Q_UNUSED(value) }"
+HINT = "class Utility;\nclass Host : public QObject { Q_OBJECT };\n"
+
+
+def utility(root, filename="utility.cpp", *, hint=False):
+ sources = {"utility.hpp": HEADER, filename: '#include "utility.hpp"\n' + METHOD + "\n"}
+ if hint:
+ sources[filename] = sources[filename].replace("Q_UNUSED(value)", "(void)value;")
+ sources["host.hpp"] = HINT
+ result = analysis(root, sources)
+ member = next(node for node in sites(result, "member")
+ if qt_metadata(node)["class_name"] == "Utility")
+ return result, member
+
+
+def occurrence(result, kind):
+ values = sites(result, kind)
+ if kind == "member":
+ values = [node for node in values if qt_metadata(node)["class_name"] == "Utility"]
+ assert len(values) == 1
+ return values[0]
+
+
+def accepted_generic(result):
+ nodes = copy.deepcopy([node for node in result["nodes"] if not qt_metadata(node)])
+ ids = {node["id"] for node in nodes}
+ edges = copy.deepcopy([edge for edge in result["edges"]
+ if not qt_metadata(edge) and edge["source"] in ids and edge["target"] in ids])
+ return nodes, edges
+
+
+def assert_source_link(result, site, target, original):
+ metadata = qt_metadata(site)
+ assert metadata["owner_id"] == target
+ assert metadata["class_id"] == ""
+ span = metadata["span"]
+ assert original[span["start_byte"]:span["end_byte"]] in (
+ METHOD.encode(), METHOD.replace("Q_UNUSED(value)", "(void)value;").encode(), b"struct Row { int entry; }")
+ links = [edge for edge in result["edges"] if edge["source"] == target and edge["target"] == site["id"]]
+ assert len(links) == 1
+ assert links[0]["relation"] == "contains" and links[0]["confidence"] == "EXTRACTED"
+ assert links[0]["context"] == "qt_source_site"
+ assert qt_metadata(links[0])["span"] == span
+
+
+@pytest.mark.parametrize("filename,hint", [("utility.cpp", False), ("writer.cpp", False),
+ ("writer.cpp", True)])
+def test_req_qml008_ac02_plain_member_uses_exact_callable_without_native_class_claim(tmp_path, filename, hint):
+ result, member = utility(tmp_path, filename, hint=hint)
+ metadata = qt_metadata(member)
+ target = metadata["generic_target_id"]
+ declaration = next(node for node in result["nodes"] if node["id"] == target)
+ assert metadata["status"] == "resolved"
+ assert declaration.get("_callable") is True and not declaration.get("_callable_class")
+ assert not metadata["roles"] and metadata["class_id"] == ""
+ assert metadata["owner_id"] == target
+ assert not is_qt_cpp_source(tmp_path / "utility.hpp")
+ assert not [node for node in sites(result, "class") if qt_metadata(node).get("class_name") == "Utility"
+ and qt_metadata(node).get("is_definition") is True]
+ if hint:
+ assert not is_qt_cpp_source(tmp_path / filename)
+ assert is_qt_cpp_source(tmp_path / filename, class_names=["Utility"])
+ forwards = [node for node in sites(result, "class") if qt_metadata(node)["class_name"] == "Utility"]
+ assert len(forwards) == 1 and qt_metadata(forwards[0])["is_definition"] is False
+ assert_source_link(result, member, target, (tmp_path / filename).read_bytes())
+ index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path)
+ assert not index.provider_records and index.module_type("Demo", 1, 0, "Utility").status == "unavailable"
+
+
+def test_req_qml008_ac02_source_link_survives_build_json_and_aggregate(tmp_path):
+ result, member = utility(tmp_path)
+ target = qt_metadata(member)["generic_target_id"]
+ graph = build_from_json(result, root=tmp_path)
+ assert graph.has_edge(target, member["id"]) and graph.degree(member["id"]) > 0
+ groups = cluster(graph)
+ community = next(values for values in groups.values() if member["id"] in values)
+ assert target in community and len(community) > 1
+ output = tmp_path / "graph.json"
+ assert to_json(graph, groups, str(output))
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ edge = restored.edges[target, member["id"]]
+ assert (edge["_src"], edge["_tgt"]) == (target, member["id"])
+ assert edge["relation"] == "contains" and edge["context"] == "qt_source_site"
+ assert qt_metadata(dict(restored.nodes[member["id"]])) == qt_metadata(member)
+ html = tmp_path / "graph.html"
+ assert to_html(restored, groups, str(html), node_limit=1)
+ match = re.search(r"const RAW_NODES = (\[.*?\]);", html.read_text(encoding="utf-8"), re.S)
+ assert match
+ payload = json.loads(match[1])
+ assert not [node for node in payload if node.get("member_count") == 1 and "Qt member" in node["label"]]
+
+
+def test_req_qml008_ac02_local_class_links_to_enclosing_callable_without_type_authority(tmp_path):
+ result = analysis(tmp_path, {"local.cpp": LOCAL + "\n"})
+ local = occurrence(result, "class")
+ function = next(node for node in result["nodes"] if node.get("_callable")
+ and node.get("source_file") == "local.cpp" and node["label"] == "collect()")
+ metadata = qt_metadata(local)
+ assert metadata["is_definition"] is True and metadata["status"] == "unavailable"
+ assert metadata["generic_target_id"] == "" and metadata["class_id"] == ""
+ assert not metadata["is_qobject"] and not metadata["source_q_object"]
+ assert_source_link(result, local, function["id"], LOCAL.encode())
+ assert not build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path).provider_records
+
+
+@pytest.mark.parametrize("kind", ["member", "class"])
+def test_req_qml008_ac02_direct_collector_borrows_exact_callable_without_mutating_context(tmp_path, kind):
+ if kind == "member":
+ accepted, _ = utility(tmp_path, "writer.cpp")
+ path = tmp_path / "writer.cpp"
+ target = qt_metadata(occurrence(accepted, kind))["generic_target_id"]
+ else:
+ accepted = analysis(tmp_path, {"local.cpp": LOCAL + "\n"})
+ path = tmp_path / "local.cpp"
+ target = next(node["id"] for node in accepted["nodes"] if node.get("_callable")
+ and node["label"] == "collect()")
+ nodes, edges = accepted_generic(accepted)
+ before = copy.deepcopy((nodes, edges))
+ fresh = {"nodes": [], "edges": []}
+ enrich_qt_cpp([path], [fresh], root=tmp_path, accepted_nodes=nodes, accepted_edges=edges)
+ assert (nodes, edges) == before
+ assert not fresh.get("qml_failures")
+ assert_source_link(fresh, occurrence(fresh, kind), target, path.read_bytes())
+
+
+@pytest.mark.parametrize("kind", ["member", "class"])
+@pytest.mark.parametrize("corruption", ["missing", "wrong_file", "wrong_line", "ambiguous",
+ "not_callable", "class_like"])
+def test_req_qml008_ac02_source_fallback_rejects_unproved_or_noncallable_context(tmp_path, kind, corruption):
+ if kind == "member":
+ accepted, _ = utility(tmp_path, "writer.cpp")
+ path = tmp_path / "writer.cpp"
+ target = qt_metadata(occurrence(accepted, kind))["generic_target_id"]
+ else:
+ accepted = analysis(tmp_path, {"local.cpp": LOCAL + "\n"})
+ path = tmp_path / "local.cpp"
+ target = next(node["id"] for node in accepted["nodes"] if node.get("_callable")
+ and node["label"] == "collect()")
+ nodes, edges = accepted_generic(accepted)
+ chosen = next(node for node in nodes if node["id"] == target)
+ if corruption == "missing":
+ nodes.remove(chosen)
+ elif corruption == "wrong_file":
+ chosen["source_file"] = "foreign.cpp"
+ elif corruption == "wrong_line":
+ chosen["source_location"] = "L100"
+ elif corruption == "ambiguous":
+ nodes.append({**copy.deepcopy(chosen), "id": target + "_different"})
+ elif corruption == "not_callable":
+ chosen["_callable"] = False
+ elif corruption == "class_like":
+ chosen["_callable_class"] = True
+ before = copy.deepcopy((nodes, edges))
+ fresh = {"nodes": [], "edges": []}
+ enrich_qt_cpp([path], [fresh], root=tmp_path, accepted_nodes=nodes, accepted_edges=edges)
+ assert (nodes, edges) == before
+ assert not fresh.get("qml_failures")
+ site = occurrence(fresh, kind)
+ metadata = qt_metadata(site)
+ assert metadata["owner_id"] == "" and metadata["class_id"] == ""
+ if kind == "member" and corruption == "class_like":
+ assert metadata["status"] == "resolved" and metadata["generic_target_id"] == target
+ assert not [edge for edge in fresh["edges"] if edge["target"] == site["id"]
+ and edge["context"] == "qt_source_site"]
diff --git a/tests/test_qt_metadata_admission.py b/tests/test_qt_metadata_admission.py
new file mode 100644
index 0000000000..bd46f88510
--- /dev/null
+++ b/tests/test_qt_metadata_admission.py
@@ -0,0 +1,87 @@
+"""Qt metadata reaches deterministic readers under existing discovery policy."""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Callable, cast
+
+import pytest
+
+from graphify.detect import FileType, classify_file, detect
+from graphify.extractors import LANGUAGE_EXTRACTORS
+from graphify.extractors.qml_facts import qml_metadata
+
+
+CASES = [
+ ("CMakeLists.txt", "cmake", "qt_add_qml_module(tools URI Public.Tools VERSION 1.0 QML_FILES Main.qml)\n", "qt_module"),
+ ("module.cmake", "cmake", "qt_add_qml_module(tools URI Public.Tools VERSION 1.0)\n", "qt_module"),
+ ("tools.pro", "qmake", "QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\n", "qt_module"),
+ ("tools.pri", "qmake", "QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\n", "qt_module"),
+ ("resources.qrc", "qrc", 'Main.qml', "resource_alias"),
+ ("plugins.qmltypes", "qmltypes", 'import QtQuick.tooling 1.2\nModule { Component { name: "Service"; exports: ["Public.Tools/Service 1.0"] } }', "qmltypes_export"),
+]
+
+
+@pytest.mark.parametrize("name,key,source,kind", CASES, ids=[case[0] for case in CASES])
+def test_qml009_metadata_classification_registry_and_original_source_facts(tmp_path, name, key, source, kind):
+ path = tmp_path / name
+ original = b"\xef\xbb\xbf" + source.replace("\n", "\r\n").encode("utf-8")
+ path.write_bytes(original)
+ assert classify_file(path) == FileType.CODE
+ discovered = detect(tmp_path)
+ assert str(path) in discovered["files"]["code"]
+ result = cast(Callable[..., dict], LANGUAGE_EXTRACTORS[key])(path, root=tmp_path)
+ assert not result.get("error") and not result.get("qml_failures"), result
+ facts = [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind]
+ assert facts
+ assert all(node["source_file"] == name for node in facts)
+ for fact in facts:
+ span = qml_metadata(fact)["span"]
+ # qmake combines source lines into a module record, including the first
+ # line's BOM; syntax-backed CMake/qmltypes records start after it.
+ assert 0 <= span["start_byte"] < span["end_byte"] <= len(original)
+ assert original[span["start_byte"]:span["end_byte"]].decode("utf-8")
+
+
+def test_cmake_exact_name_does_not_reclassify_arbitrary_text():
+ assert classify_file(Path("CMakeLists.txt")) == FileType.CODE
+ for name in ("cmakelists.txt", "CMakeLists.TXT", "CMakeLists.txt.bak", "notes.txt"):
+ assert classify_file(Path(name)) != FileType.CODE
+ assert classify_file(Path("metadata.QRC")) == FileType.CODE
+ assert classify_file(Path("component.UI.QML")) == FileType.CODE
+
+
+@pytest.mark.parametrize("ignore_name", [".graphifyignore", ".gitignore"])
+def test_qt_metadata_discovery_obeys_existing_ignore_policy(tmp_path, ignore_name):
+ accepted = tmp_path / "CMakeLists.txt"
+ accepted.write_text("qt_add_qml_module(tools URI Public.Tools)\n", encoding="utf-8")
+ ignored = tmp_path / "ignored"
+ ignored.mkdir()
+ for name, _, source, _ in CASES:
+ (ignored / name).write_text(source, encoding="utf-8")
+ (tmp_path / ignore_name).write_text("ignored/\n", encoding="utf-8")
+ result = detect(tmp_path)
+ assert str(accepted) in result["files"]["code"]
+ assert not any("ignored" in Path(value).relative_to(tmp_path).parts for value in result["files"]["code"])
+
+
+def test_new_metadata_admission_preserves_credential_directory_exclusion(tmp_path):
+ credentials = tmp_path / ".ssh"
+ credentials.mkdir()
+ for name, _, source, _ in CASES:
+ (credentials / name).write_text(source, encoding="utf-8")
+ result = detect(tmp_path)
+ assert result["files"]["code"] == []
+
+
+@pytest.mark.parametrize("key,source", [
+ ("cmake", "if(ENABLE_FEATURE)\nqt_add_qml_module(tools URI Public.Tools)\nendif()\n"),
+ ("qmake", "CONFIG(debug): QML_IMPORT_NAME = Public.Tools\n"),
+ ("qrc", ']>&leak;'),
+ ("qmltypes", "Module { Component { name: computedName() } }"),
+])
+def test_registered_readers_report_unsupported_partial_inputs(tmp_path, key, source):
+ path = tmp_path / {"cmake": "CMakeLists.txt", "qmake": "tools.pro", "qrc": "resources.qrc", "qmltypes": "plugins.qmltypes"}[key]
+ path.write_text(source, encoding="utf-8")
+ result = cast(Callable[..., dict], LANGUAGE_EXTRACTORS[key])(path, root=tmp_path)
+ assert result.get("qml_failures"), result
+ assert result.get("partial") or result.get("error")
diff --git a/tests/test_qt_metadata_boundaries.py b/tests/test_qt_metadata_boundaries.py
new file mode 100644
index 0000000000..f3f405ca89
--- /dev/null
+++ b/tests/test_qt_metadata_boundaries.py
@@ -0,0 +1,99 @@
+"""Adversarial work bounds and syntax/read failures at actual metadata interfaces."""
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from graphify.extractors.qml_cmake import extract_cmake, parse_cmake
+from graphify.extractors.qml_project_read import MAX_PROJECT_BYTES
+from graphify.extractors.qml_qmake import parse_qmake
+from graphify.extractors.qml_resources import parse_qrc
+from graphify.extractors.qml_types import parse_qmltypes
+from graphify.qt_resource_index import normalize_url
+from graphify.qml_resolution_types import Resolution
+
+
+@pytest.mark.parametrize("source", [
+ "# comment without newline", '#[[ unterminated comment',
+ 'qt_add_qml_module(app URI "unclosed)', 'qt_add_qml_module(app URI Public.Tools',
+ 'qt_add_qml_module(app URI A URI B)', 'qt_add_qml_module(app URI)',
+ 'qt_add_qml_module(app URI bad-name VERSION 1.0)', 'endif()',
+ 'function(f)\nendmacro()', 'qt_add_qml_module(app URI A QML_FILES a; b)',
+])
+def test_cmake_comments_and_syntax_boundary(source):
+ result = parse_cmake(source)
+ if source == "# comment without newline":
+ assert not result.get("error")
+ else:
+ assert result["qml_failures"]
+
+
+def test_cmake_scope_limit_and_source_file_growth_are_rejected(tmp_path, monkeypatch):
+ result = parse_cmake("if(A)\n" * 65 + "endif()\n" * 65)
+ assert result["diagnostics"][0]["reason"] == "cmake_scope_limit"
+ path = tmp_path / "CMakeLists.txt"
+ path.write_bytes(b"x" * (MAX_PROJECT_BYTES + 1))
+ original_stat = Path.stat
+ # Understate only the size so the real post-read growth guard is exercised;
+ # native spelling admission still needs the actual mode and file identity.
+ def changed_stat(self, *args, **kwargs):
+ actual = original_stat(self, *args, **kwargs)
+ if self == path:
+ fields = {name: getattr(actual, name) for name in dir(actual) if name.startswith("st_")}
+ return SimpleNamespace(**(fields | {"st_size": 1}))
+ return actual
+ monkeypatch.setattr(Path, "stat", changed_stat)
+ actual, understated = original_stat(path), path.stat()
+ assert understated.st_size == 1
+ assert all(getattr(understated, name) == getattr(actual, name)
+ for name in dir(actual) if name.startswith("st_") and name != "st_size")
+ assert path.samefile(path)
+ assert extract_cmake(path, root=tmp_path)["diagnostics"][0]["reason"] == "metadata_size_limit"
+
+
+def test_fact_and_token_work_limits_reject_authoritative_partial_results(monkeypatch):
+ monkeypatch.setattr("graphify.extractors.qml_project_read.MAX_PROJECT_FACTS", 2)
+ result = parse_cmake("qt_add_qml_module(app URI Public.Tools QML_FILES Main.qml)")
+ assert result["diagnostics"][0]["reason"] == "metadata_fact_limit"
+ assert result["nodes"] == []
+ result = parse_cmake("message(" + "x " * 10_001 + ")")
+ assert result["diagnostics"][0]["reason"] == "cmake_token_limit"
+
+
+@pytest.mark.parametrize("source", [
+ "}\n", "unix {\n", 'QML_IMPORT_NAME = "unclosed',
+ "QML_IMPORT_NAME += A B", "QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = 1.0\nTARGET += a b",
+ "QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = 1.0\nHEADERS += *.h",
+])
+def test_qmake_unbalanced_and_malformed_state_never_creates_context(source):
+ assert parse_qmake(source)["qml_failures"]
+
+
+@pytest.mark.parametrize("source", [
+ '', '',
+ 'Main.qml',
+ '', 'unexpected',
+ 'Main.qml',
+])
+def test_unsupported_xml_forms_are_explicit_partial_failures(source):
+ assert parse_qrc(source)["qml_failures"]
+
+
+@pytest.mark.parametrize("source", [
+ 'Module { Component { name: "A"; name: "B" } }',
+ 'Module { Component { name: "A"; Property { type: "int" } } }',
+ 'Module { Component { name: "A"; Unknown { name: "p" } } }',
+ 'Module { Component { name: "A"; Property { name: "p"; Parameter { name: "x" } } } }',
+ 'Module { Component { name: "A"; Method { name: "f"; Unknown { name: "x" } } } }',
+ 'Module { Component { name: "A"; Method { name: "f"; Parameter { Unknown {} } } } }',
+ 'Module { Unknown {} }', 'Module { function computed() {} }',
+ 'var value = 1; Module {}', 'Module { Component { name: "A"; exports: "A/B 1.0" } }',
+])
+def test_qmltypes_structure_cannot_be_interpreted_as_executable_qml(source):
+ assert parse_qmltypes(source)["qml_failures"]
+
+
+def test_invalid_url_grammar_returns_scoped_resolution_instead_of_reading():
+ for url in ("", "x" * 385, "qrc:Main.qml", "https://host/Main.qml", "qrc://[invalid/",
+ "qrc:/%ff.qml"):
+ assert isinstance(normalize_url(url), Resolution)
diff --git a/tests/test_qt_metadata_incremental.py b/tests/test_qt_metadata_incremental.py
new file mode 100644
index 0000000000..c81fc39346
--- /dev/null
+++ b/tests/test_qt_metadata_incremental.py
@@ -0,0 +1,153 @@
+"""Real QML-011 provider-only updates must equal accepted-corpus clean rebuilds."""
+import json
+
+import pytest
+
+import graphify.extract as ex
+from graphify.build import build_from_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.watch import _rebuild_code
+
+
+def project(root):
+ files = {
+ "Main.qml": 'import Public.Tools 1.0\nService { property int displayed: status }\n',
+ "Other.qml": 'Item { property int other: 2 }\n',
+ "backend.h": '#include \nclass Backend : public QObject { Q_OBJECT '
+ 'QML_NAMED_ELEMENT(Service) Q_PROPERTY(int status READ status) '
+ 'public: int status() const { return 1; } };\n',
+ "loader.cpp": '#include \nvoid load() { '
+ 'QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml")); }\n',
+ "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 '
+ 'RESOURCE_PREFIX /ui QML_FILES Main.qml Other.qml SOURCES backend.h loader.cpp)\n',
+ "resources.qrc": 'Main.qml',
+ }
+ for name, source in files.items():
+ (root / name).write_bytes(source.encode())
+ return root / "CMakeLists.txt", root / "backend.h", root / "resources.qrc"
+
+
+def normalized(graph):
+ nodes = {nid: (data.get("source_file"), qml_metadata(data), qt_metadata(data))
+ for nid, data in graph.nodes(data=True)
+ if qml_metadata(data) or qt_metadata(data)}
+ edges = {(data.get("_src", source), data.get("_tgt", target), data.get("relation"),
+ data.get("context"), data.get("confidence"),
+ json.dumps(qml_metadata(data), sort_keys=True), json.dumps(qt_metadata(data), sort_keys=True))
+ for source, target, data in graph.edges(data=True)
+ if qml_metadata(data) or qt_metadata(data)}
+ return nodes, edges
+
+
+def published(root):
+ return load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8")))
+
+
+def compare_clean(root):
+ paths = ex.collect_files(root, root=root)
+ fresh = ex.extract(paths, root=root, cache_root=root / "clean-cache", parallel=False)
+ assert not fresh.get("qml_failures")
+ assert normalized(published(root)) == normalized(build_from_json(fresh, root=root))
+
+
+def test_native_cpp_provider_only_edit_refreshes_unchanged_qml_and_warm_overlay(tmp_path, monkeypatch):
+ _, header, _ = project(tmp_path)
+ assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False)
+ initial = published(tmp_path)
+ # Native role views are read-only adapters; canonical C++ nodes are untouched.
+ native_reads = [(source, target, data) for source, target, data in initial.edges(data=True)
+ if data.get("context") == "qml_binding_read" and qt_metadata(data).get("native_endpoint")]
+ assert len(native_reads) == 1
+ source, target, data = native_reads[0]
+ proof = qt_metadata(data)["native_endpoint"]
+ assert proof["canonical_target_id"] == data.get("_tgt", target)
+ assert proof["member_fact_id"] == proof["canonical_target_id"]
+ assert qt_metadata(initial.nodes[proof["member_fact_id"]])["raw_name"] == "status"
+ assert initial.nodes[proof["class_id"]]["source_file"] == "backend.h"
+ assert qt_metadata(initial.nodes[proof["provider_id"]])["raw_name"] == "Service"
+ assert any(qml_metadata(initial.nodes[item]).get("kind") == "qt_module" for item in proof["evidence"])
+ assert qml_metadata(initial.nodes[data.get("_src", source)])["status"] == "resolved"
+ header.write_bytes(header.read_bytes().replace(b"Service", b"RenamedService"))
+ observed, real_extract = [], ex.extract
+ def observe(paths, **kwargs):
+ observed.extend(paths)
+ return real_extract(paths, **kwargs)
+ monkeypatch.setattr(ex, "extract", observe)
+ assert _rebuild_code(tmp_path, changed_paths=[header], no_cluster=True, acquire_lock=False)
+ assert tmp_path / "Main.qml" in observed
+ compare_clean(tmp_path)
+ warm = normalized(published(tmp_path))
+ assert _rebuild_code(tmp_path, changed_paths=[header], no_cluster=True, acquire_lock=False)
+ assert normalized(published(tmp_path)) == warm
+
+
+@pytest.mark.parametrize("mutation", ["uri", "delete", "rename", "duplicate", "ignore"])
+def test_build_provider_only_mutations_equal_clean_accepted_corpus(tmp_path, mutation):
+ build, _, _ = project(tmp_path)
+ assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False)
+ assert any(qml_metadata(node).get("kind") == "qt_module" for _, node in published(tmp_path).nodes(data=True))
+ changed = [build]
+ if mutation == "uri":
+ build.write_bytes(build.read_bytes().replace(b"Public.Tools", b"Other.Tools"))
+ elif mutation == "delete":
+ build.unlink()
+ elif mutation == "rename":
+ renamed = tmp_path / "renamed.cmake"
+ build.rename(renamed)
+ changed.append(renamed)
+ elif mutation == "duplicate":
+ duplicate = tmp_path / "duplicate.cmake"
+ duplicate.write_bytes(build.read_bytes().replace(b"app ", b"other "))
+ changed.append(duplicate)
+ elif mutation == "ignore":
+ ignore = tmp_path / ".graphifyignore"
+ ignore.write_bytes(b"CMakeLists.txt\n")
+ changed = [ignore]
+ assert _rebuild_code(tmp_path, changed_paths=changed, no_cluster=True, acquire_lock=False)
+ compare_clean(tmp_path)
+
+
+@pytest.mark.parametrize("mutation", ["retarget", "delete", "rename", "ignore"])
+def test_resource_only_mutations_refresh_unchanged_cpp_loaders(tmp_path, mutation, monkeypatch):
+ _, _, qrc = project(tmp_path)
+ assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False)
+ assert any(qml_metadata(node).get("kind") == "resource_alias" for _, node in published(tmp_path).nodes(data=True))
+ changed = [qrc]
+ if mutation == "retarget":
+ qrc.write_bytes(qrc.read_bytes().replace(b">Main.qml<", b">Other.qml<"))
+ elif mutation == "delete":
+ qrc.unlink()
+ elif mutation == "rename":
+ renamed = tmp_path / "renamed.qrc"
+ qrc.rename(renamed)
+ changed.append(renamed)
+ elif mutation == "ignore":
+ ignore = tmp_path / ".graphifyignore"
+ ignore.write_bytes(b"resources.qrc\n")
+ changed = [ignore]
+ observed, real_extract = [], ex.extract
+ def observe(paths, **kwargs):
+ observed.extend(paths)
+ return real_extract(paths, **kwargs)
+ monkeypatch.setattr(ex, "extract", observe)
+ assert _rebuild_code(tmp_path, changed_paths=changed, no_cluster=True, acquire_lock=False)
+ assert tmp_path / "loader.cpp" in observed
+ compare_clean(tmp_path)
+
+
+@pytest.mark.parametrize("name,invalid", [
+ ("CMakeLists.txt", b"if(WIN32)\nqt_add_qml_module(app URI Hidden VERSION 1.0)\nendif()"),
+ ("resources.qrc", b']>'),
+])
+def test_failed_metadata_update_preserves_prior_graph_manifest_and_report(tmp_path, name, invalid):
+ project(tmp_path)
+ assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False)
+ out = tmp_path / "graphify-out"
+ prior = {p.name: p.read_bytes() for p in out.iterdir() if p.is_file() and p.name in {
+ "graph.json", "manifest.json", "report.md"}}
+ path = tmp_path / name
+ path.write_bytes(invalid)
+ assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True, acquire_lock=False, force=True)
+ assert {name: (out / name).read_bytes() for name in prior} == prior
diff --git a/tests/test_qt_native_alias_scope.py b/tests/test_qt_native_alias_scope.py
new file mode 100644
index 0000000000..85f7abd74b
--- /dev/null
+++ b/tests/test_qt_native_alias_scope.py
@@ -0,0 +1,288 @@
+"""REQ-QML-016/008 lexical type authority through completed graph publication.
+
+These production regressions promote the public opt-in alias audit. Unknown
+targets remain acceptable for unsupported bindings; simple proven aliases must
+resolve their actual class. Fixtures never execute C++, QML or project hooks.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.affected import load_graph
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_qml_bridge import build_qt_qml_bridge
+
+DECLARATIONS = """class Sender : public QObject { Q_OBJECT
+signals: void changed(int value);
+};
+class Receiver : public QObject { Q_OBJECT
+public: void accept(int value) {}
+};
+class Other : public QObject { Q_OBJECT
+signals: void changed(int value);
+};
+"""
+ALIASES = ["using Sender = Other;", "typedef Other Sender;"]
+QML = "import Demo 1.0\nThing { onChanged: (value) => {} }\n"
+
+
+def persisted(root, source, *, qml=False):
+ """Synthetic bytes enter the real facade, assembly, JSON writer and loader."""
+ path = root / "events.cpp"
+ path.write_bytes(source.encode("utf-8"))
+ paths = [path]
+ if qml:
+ qml_path = root / "Main.qml"
+ qml_path.write_bytes(QML.encode("utf-8"))
+ paths.append(qml_path)
+ result = extract(paths, root=root, cache_root=root / "cache", parallel=False,
+ qml_import_roots=(".",))
+ assert not result.get("qml_failures") and not result.get("qt_failures")
+ output = root / "proof.json"
+ graph = build_from_json(result, root=root)
+ assert to_json(graph, {}, str(output), force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ reloaded = load_graph(output)
+ assert reloaded.is_directed()
+ # Evidence belongs to original file bytes, not normalized source/graph labels.
+ for node in payload["nodes"]:
+ metadata = qt_metadata(node) or qml_metadata(node)
+ if not metadata or not metadata.get("span"):
+ continue
+ data = (root / node["source_file"]).read_bytes()
+ span = metadata["span"]
+ start, end = span["start_byte"], span["end_byte"]
+ assert 0 <= start <= end <= len(data)
+ for prefix, offset in (("start", start), ("end", end)):
+ preceding = data[:offset]
+ assert span[prefix + "_row"] == preceding.count(b"\n")
+ assert span[prefix + "_column"] == len(preceding.rsplit(b"\n", 1)[-1])
+ return result, payload, reloaded
+
+
+def native_facts(result, kind):
+ return [node for node in result["nodes"] if qt_metadata(node).get("kind") == kind]
+
+
+def native_signal(result, class_name):
+ return next(node for node in native_facts(result, "member")
+ if qt_metadata(node).get("class_name") == class_name
+ and qt_metadata(node).get("raw_name") == "changed")
+
+
+def endpoint_links(payload, site):
+ """Role nodes retain logical direction after default undirected assembly."""
+ role_ids = {node["id"] for node in payload["nodes"]
+ if qt_metadata(node).get("kind") == "event_endpoint"
+ and qt_metadata(node).get("owner_id") == site["id"]}
+ return [edge for edge in payload["links"]
+ if edge["source"] in role_ids | {site["id"]}
+ and edge.get("context") in {"qt_connect_signal", "qt_signal_emit"}]
+
+
+def assert_no_wrong_signal(result, payload, graph, site):
+ """Unsupported aliases may remain unresolved; wrong persisted edges may not."""
+ wrong = native_signal(result, "Sender")
+ forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]}
+ links = endpoint_links(payload, site)
+ assert not [edge for edge in links if edge["target"] in forbidden], links
+ for edge in links:
+ assert graph.has_edge(edge["source"], edge["target"])
+ metadata = qt_metadata(site)
+ assert metadata.get("signal_target_id") not in forbidden, metadata
+ assert metadata.get("target_id") not in forbidden, metadata
+
+
+@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"])
+def test_req_qml016_connect_alias_cannot_select_unrelated_global_signal(tmp_path, alias):
+ """A local alias shadows Sender; connect must not persist global Sender's signal."""
+ source = DECLARATIONS + f"""void wire(Other *original, Receiver *receiver) {{
+ {alias}
+ Sender *sender = original;
+ QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);
+}}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ site = native_facts(result, "connect")[0]
+ assert_no_wrong_signal(result, payload, graph, site)
+
+
+@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"])
+def test_req_qml016_emission_alias_cannot_select_unrelated_global_signal(tmp_path, alias):
+ """An explicit alias-typed receiver supplies no authority for the global class."""
+ source = DECLARATIONS + f"""void send(Other *original) {{
+ {alias}
+ Sender *sender = original;
+ emit sender->changed(1);
+}}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ assert_no_wrong_signal(result, payload, graph, native_facts(result, "emission")[0])
+
+
+def registration_proof(root, alias, raw_type="Sender"):
+ source = DECLARATIONS + f"""void install() {{
+ {alias}
+ qmlRegisterType<{raw_type}>("Demo", 1, 0, "Thing");
+}}
+"""
+ result, payload, graph = persisted(root, source, qml=True)
+ bridge = build_qt_qml_bridge(result["nodes"], result["edges"], root=root)
+ provider = bridge.module_type("Demo", 1, 0, "Thing")
+ handlers = [node for node in payload["nodes"] if qml_metadata(node).get("kind") == "handler"]
+ assert len(handlers) == 1
+ return result, payload, graph, bridge, provider, handlers[0]
+
+
+@pytest.mark.parametrize("alias", ALIASES, ids=["using", "typedef"])
+def test_req_qml008_alias_registration_cannot_export_global_class_to_qml(tmp_path, alias):
+ """Completed native registration→QML handler edges must honor type authority."""
+ result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, alias)
+ wrong = native_signal(result, "Sender")
+ forbidden = {wrong["id"], qt_metadata(wrong)["generic_target_id"]}
+ links = [edge for edge in payload["links"] if edge["source"] == handler["id"]
+ and edge.get("context") == "qml_signal_subscription"]
+ assert not [edge for edge in links if edge["target"] in forbidden], links
+ assert qml_metadata(handler).get("resolved_target_id") not in forbidden
+ wrong_class = qt_metadata(wrong)["class_id"]
+ assert qt_metadata(native_facts(result, "registration")[0]).get("class_id") != wrong_class
+ if provider.target_id:
+ assert bridge.provider_records[provider.target_id]["class_id"] != wrong_class
+ for edge in links:
+ assert graph.has_edge(edge["source"], edge["target"])
+
+
+def test_req_qml016_block_alias_does_not_change_before_and_after_native_scope(tmp_path):
+ """The alias applies inside its block; globally suppressing Sender is insufficient."""
+ source = DECLARATIONS + """void wire(Sender *outer, Other *other, Receiver *receiver) {
+ QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept);
+ { using Sender = Other; Sender *inner = other;
+ QObject::connect(inner, &Sender::changed, receiver, &Receiver::accept); }
+ QObject::connect(outer, &Sender::changed, receiver, &Receiver::accept);
+}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ before, inside, after = native_facts(result, "connect")
+ target = native_signal(result, "Sender")["id"]
+ assert qt_metadata(before)["signal_target_id"] == target
+ assert qt_metadata(after)["signal_target_id"] == target
+ assert {edge["target"] for edge in endpoint_links(payload, before)} == {target}
+ assert {edge["target"] for edge in endpoint_links(payload, after)} == {target}
+ assert_no_wrong_signal(result, payload, graph, inside)
+
+
+@pytest.mark.parametrize("class_name", ["Sender", "Other"])
+def test_direct_native_type_control_preserves_roles_and_direction(tmp_path, class_name):
+ """Unshadowed connections/emissions retain distinct roles and logical direction."""
+ source = DECLARATIONS + f"""void wire({class_name} *sender, Receiver *receiver) {{
+ QObject::connect(sender, &{class_name}::changed, receiver, &Receiver::accept);
+ emit sender->changed(1);
+}}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ connection = native_facts(result, "connect")[0]
+ emission = native_facts(result, "emission")[0]
+ signal = native_signal(result, class_name)["id"]
+ assert qt_metadata(connection)["status"] == qt_metadata(emission)["status"] == "resolved"
+ for site in (connection, emission):
+ links = endpoint_links(payload, site)
+ assert len(links) == 1 and links[0]["target"] == signal
+ assert graph.has_edge(links[0]["source"], signal)
+ assert not graph.has_edge(signal, links[0]["source"])
+ roles = [node for node in payload["nodes"]
+ if qt_metadata(node).get("kind") == "event_endpoint"
+ and qt_metadata(node).get("owner_id") == connection["id"]]
+ assert {qt_metadata(node)["role"] for node in roles} == {"signal", "receiver"}
+ assert len({node["id"] for node in roles}) == 2
+ assert not any(edge["source"] == connection["id"] and edge.get("relation") == "calls"
+ for edge in payload["links"])
+
+
+@pytest.mark.parametrize("class_name", ["Sender", "Other"])
+def test_direct_registration_control_preserves_native_qml_endpoint(tmp_path, class_name):
+ """A literal unshadowed registration exports only its actual native signal."""
+ result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, "", class_name)
+ signal = native_signal(result, class_name)
+ assert provider.status == "resolved"
+ assert provider.target_id is not None
+ assert bridge.provider_records[provider.target_id]["class_id"] == qt_metadata(signal)["class_id"]
+ target = qt_metadata(signal)["generic_target_id"]
+ assert qml_metadata(handler)["status"] == "resolved"
+ assert qml_metadata(handler)["resolved_target_id"] == target
+ links = [edge for edge in payload["links"] if edge["source"] == handler["id"]
+ and edge.get("context") == "qml_signal_subscription"]
+ assert len(links) == 1 and links[0]["target"] == target
+ assert graph.has_edge(handler["id"], target) and not graph.has_edge(target, handler["id"])
+
+
+@pytest.mark.parametrize("alias", [*ALIASES, "typedef Other First, Sender;"],
+ ids=["using", "typedef", "multiple"])
+def test_proven_alias_registration_exports_the_actual_class_and_qml_signal(tmp_path, alias):
+ """A literal alias requires positive canonical provider and persisted endpoint proof."""
+ result, payload, graph, bridge, provider, handler = registration_proof(tmp_path, alias)
+ signal = native_signal(result, "Other")
+ assert provider.status == "resolved"
+ assert provider.target_id is not None
+ assert bridge.provider_records[provider.target_id]["class_id"] == qt_metadata(signal)["class_id"]
+ target = qt_metadata(signal)["generic_target_id"]
+ assert qml_metadata(handler)["resolved_target_id"] == target
+ assert graph.has_edge(handler["id"], target)
+ assert any(edge["source"] == handler["id"] and edge["target"] == target
+ and edge.get("context") == "qml_signal_subscription" for edge in payload["links"])
+
+
+@pytest.mark.parametrize("alias,expected", [
+ ("using Sender = Other;", "Other"), ("typedef Other Sender;", "Other"),
+ ("typedef Other First, Sender;", "Other"),
+ ("using First = Other; using Sender = First;", "Other"),
+ ("#if ENABLED\nusing Sender = Other;\n#endif", None),
+ ("using Sender = Other; using Sender = Receiver;", None),
+ ("using First = Second; using Second = First; using Sender = First;", None),
+ ("typedef Other *Sender;", None), ("using Sender = decltype(factory());", None),
+])
+def test_local_alias_positive_and_rejection_profile(tmp_path, alias, expected):
+ """Aliases own local type evidence; uncertainty cannot select an outer global class."""
+ source = DECLARATIONS + f"""void wire(Other *original, Receiver *receiver) {{
+ {alias}
+ Sender *sender = original;
+ QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);
+ emit sender->changed(1);
+}}
+"""
+ result, payload, graph = persisted(tmp_path, source)
+ connection, emission = native_facts(result, "connect")[0], native_facts(result, "emission")[0]
+ if expected:
+ target = native_signal(result, expected)["id"]
+ assert qt_metadata(connection)["signal_target_id"] == qt_metadata(emission)["target_id"] == target
+ assert qt_metadata(connection)["status"] == qt_metadata(emission)["status"] == "resolved"
+ else:
+ assert_no_wrong_signal(result, payload, graph, connection)
+ assert_no_wrong_signal(result, payload, graph, emission)
+ assert not endpoint_links(payload, connection) and not endpoint_links(payload, emission)
+
+
+@pytest.mark.parametrize("prefix,alias,expected", [
+ ("namespace App {\n" + DECLARATIONS, "", "App::Sender"),
+ (DECLARATIONS + "namespace App {\n", "using Sender = ::Other;", "Other"),
+])
+def test_namespace_lookup_and_namespace_alias_keep_original_byte_spans(tmp_path, prefix, alias, expected):
+ """Namespace authority and original BOM/CRLF/Unicode bytes survive JSON publication."""
+ source = "\ufeff// café ☃\n" + prefix + alias + """
+void wire(Sender *sender, Receiver *receiver) {
+ QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);
+ emit sender->changed(1);
+}
+}
+"""
+ result, payload, graph = persisted(tmp_path, source.replace("\n", "\r\n"))
+ target = native_signal(result, expected)["id"]
+ assert qt_metadata(native_facts(result, "connect")[0])["signal_target_id"] == target
+ assert qt_metadata(native_facts(result, "emission")[0])["target_id"] == target
+ assert any(edge["target"] == target and edge.get("context") == "qt_signal_emit"
+ and graph.has_edge(edge["source"], target) for edge in payload["links"])
diff --git a/tests/test_qt_native_alias_updates.py b/tests/test_qt_native_alias_updates.py
new file mode 100644
index 0000000000..862d776f1b
--- /dev/null
+++ b/tests/test_qt_native_alias_updates.py
@@ -0,0 +1,295 @@
+"""REQ-QML-008/016 native alias changes through real updates and consumers.
+
+Synthetic headers and source enter the production facade, CLI/watch writers and
+directed JSON loader. Alias uncertainty cannot retain an old resolved endpoint.
+"""
+from __future__ import annotations
+
+import pytest
+import networkx as nx
+
+from graphify.affected import affected_nodes, load_graph
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_analysis_state import QT_STATE_FILE
+from graphify.watch import _rebuild_code
+from tests.test_qt_cpp_upgrade_invalidation import cli
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+
+def project(root, alias="using Sender = Types::Original;"):
+ """Accepted canonical classes remain in a header while alias sites change."""
+ declaration = '''class NAME : public QObject { Q_OBJECT
+ Q_PROPERTY(int value READ value NOTIFY changed)
+ public: int value() { return 1; }
+ signals: void changed(int value);
+};
+'''
+ header = declaration.replace("NAME", "Sender")
+ header += "namespace Types {\n" + declaration.replace("NAME", "Original")
+ header += declaration.replace("NAME", "Other") + "}\n"
+ header += "class Receiver : public QObject { Q_OBJECT public: void accept(int value) {} };\n"
+ (root / "types.hpp").write_bytes(header.encode())
+ (root / "keep.py").write_text("def helper(): return 7\n\ndef retained(): return helper()\n", encoding="utf-8")
+ (root / "Main.qml").write_text(
+ "import Demo 1.0\nThing { property int displayed: value; onChanged: (value) => {} }\n", encoding="utf-8")
+ path = root / "events.cpp"
+ source = '''#include "types.hpp"
+namespace Bridge {
+ALIAS
+void wire(Sender *sender, Receiver *receiver) {
+ QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);
+ emit sender->changed(1);
+}
+void install() { qmlRegisterType("Demo", 1, 0, "Thing"); }
+}
+'''.replace("ALIAS", alias)
+ path.write_bytes(source.encode())
+ return path
+
+
+def facts(graph, kind):
+ return [(identity, qt_metadata(data)) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") == kind]
+
+
+def member(graph, class_name, name):
+ matches = [(identity, md) for identity, md in facts(graph, "member")
+ if md.get("class_name") == class_name and md.get("raw_name") == name]
+ assert len(matches) == 1
+ return matches[0]
+
+
+def link(graph, source, target, context):
+ matches = [data for _, candidate, data in graph.out_edges(source, data=True)
+ if candidate == target and data.get("context") == context]
+ assert len(matches) == 1
+ return matches[0]
+
+
+def assert_endpoints(root, expected):
+ """Qt role endpoints and QML canonical endpoints retain distinct identities."""
+ graph = load_graph(root / "graphify-out/graph.json")
+ assert isinstance(graph, (nx.DiGraph, nx.MultiDiGraph))
+ assert graph.is_directed()
+ signal_id, signal = member(graph, expected, "changed")
+ properties = [(identity, md) for identity, md in facts(graph, "property")
+ if md.get("class_name") == expected and md.get("raw_name") == "value"]
+ assert len(properties) == 1
+ property_id, prop = properties[0]
+ assert prop["read"] == "value" and signal_id != property_id
+ canonical_signal = signal["generic_target_id"]
+ connection_id, connection = facts(graph, "connect")[0]
+ emission_id, emission = facts(graph, "emission")[0]
+ registration_id, registration = facts(graph, "registration")[0]
+ assert connection["status"] == emission["status"] == "resolved"
+ assert connection["signal_target_id"] == emission["target_id"] == signal_id
+ assert registration["class_id"] == signal["class_id"]
+ assert graph.nodes[canonical_signal]["source_file"] == "types.hpp"
+ signal_roles = [identity for identity, md in facts(graph, "event_endpoint")
+ if md.get("owner_id") == connection_id and md.get("role") == "signal"]
+ assert len(signal_roles) == 1
+ role_id = signal_roles[0]
+ link(graph, role_id, signal_id, "qt_connect_signal")
+ link(graph, emission_id, signal_id, "qt_signal_emit")
+ assert not graph.has_edge(signal_id, role_id) and not graph.has_edge(signal_id, emission_id)
+ handlers = [(identity, qml_metadata(data)) for identity, data in graph.nodes(data=True)
+ if qml_metadata(data).get("kind") == "handler"]
+ assert len(handlers) == 1
+ handler_id, handler = handlers[0]
+ assert handler["resolved_target_id"] == canonical_signal
+ link(graph, handler_id, canonical_signal, "qml_signal_subscription")
+ assert not graph.has_edge(canonical_signal, handler_id)
+ reads = [(source, target, data) for source, target, data in graph.edges(data=True)
+ if data.get("context") == "qml_binding_read" and qt_metadata(data).get("native_endpoint")]
+ assert len(reads) == 1
+ proof = qt_metadata(reads[0][2])["native_endpoint"]
+ assert proof["class_id"] == signal["class_id"]
+ assert reads[0][1] != signal_id and reads[0][1] != canonical_signal
+ impacted = {hit.node_id for hit in affected_nodes(graph, signal_id, depth=2)}
+ assert {role_id, connection_id, emission_id}.issubset(impacted)
+ assert handler_id in {hit.node_id for hit in affected_nodes(graph, canonical_signal, depth=2)}
+ for identity in (connection_id, emission_id, registration_id):
+ assert not any(data.get("relation") == "calls" for _, _, data in graph.out_edges(identity, data=True))
+ return graph, (connection_id, emission_id, registration_id, handler_id)
+
+
+def setup(root, monkeypatch):
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '["."]')
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("style", ["using", "typedef"])
+def test_alias_type_edit_and_removal_refreshes_native_qml_cold_warm_consumers(
+ tmp_path, monkeypatch, operation, style
+):
+ """Changing or removing a namespace alias must replace every affected endpoint."""
+ alias = ("using Sender = Types::TARGET;" if style == "using" else "typedef Types::TARGET Sender;")
+ source = project(tmp_path, alias.replace("TARGET", "Original"))
+ setup(tmp_path, monkeypatch)
+ cache = tmp_path / ".cold-cache"
+ cold, warm = clean(tmp_path, cache), clean(tmp_path, cache)
+ assert normalized(cold) == normalized(warm)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ directed, sites = assert_endpoints(tmp_path, "Types::Original")
+ untouched = unrelated(directed)
+ source.write_bytes(source.read_bytes().replace(b"Types::Original", b"Types::Other"))
+ edited = run(tmp_path, monkeypatch, operation, [source])
+ changed, _ = assert_endpoints(tmp_path, "Types::Other")
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edit-cache"))
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edit-cache"))
+ assert unrelated(changed) == untouched
+ # Removal makes the unqualified spelling denote the actual global Sender;
+ # old Types::Other relationships must not survive in persisted consumers.
+ source.write_bytes(source.read_bytes().replace(alias.replace("TARGET", "Other").encode(), b""))
+ removed = run(tmp_path, monkeypatch, operation, [source])
+ restored, _ = assert_endpoints(tmp_path, "Sender")
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ assert unrelated(restored) == untouched
+ assert all(identity in directed for identity in sites)
+ before = (tmp_path / "graphify-out/graph.json").read_bytes()
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed)
+ assert (tmp_path / "graphify-out/graph.json").read_bytes() == before
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_unprovable_alias_replaces_old_edges_without_global_name_fallback(tmp_path, monkeypatch, operation):
+ """A valid but dynamic alias is coverage uncertainty, not stale success."""
+ source = project(tmp_path)
+ setup(tmp_path, monkeypatch)
+ run(tmp_path, monkeypatch, operation)
+ assert_endpoints(tmp_path, "Types::Original")
+ source.write_bytes(source.read_bytes().replace(b"using Sender = Types::Original;",
+ b"using Sender = decltype(factory());"))
+ updated = run(tmp_path, monkeypatch, operation, [source])
+ directed = load_graph(tmp_path / "graphify-out/graph.json")
+ assert facts(directed, "connect")[0][1]["signal_status"] != "resolved"
+ assert facts(directed, "emission")[0][1]["status"] != "resolved"
+ assert not facts(directed, "registration")[0][1].get("class_id")
+ assert not [data for _, _, data in directed.edges(data=True)
+ if data.get("context") in {"qt_connect_signal", "qt_signal_emit", "qml_signal_subscription"}]
+ assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".uncertain-cache"))
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_bad_native_alias_input_retains_outputs_then_recovers_and_repeats(tmp_path, monkeypatch, operation):
+ """The real fail-closed writer preserves graph, manifest, marker and analysis stamp."""
+ source = project(tmp_path)
+ setup(tmp_path, monkeypatch)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert_endpoints(tmp_path, "Types::Original")
+ original = source.read_bytes()
+ out = tmp_path / "graphify-out"
+ products = [out / name for name in ("graph.json", "manifest.json", ".graphify_root", QT_STATE_FILE)]
+ before = {path: path.read_bytes() for path in products}
+ source.write_bytes(original.replace(b"using Sender = Types::Original;", b"using Sender = ;"))
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ cli(tmp_path, monkeypatch, "update", force=True)
+ assert rejected.value.code not in (None, 0)
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True, force=True)
+ assert before == {path: path.read_bytes() for path in products}
+ source.write_bytes(original)
+ repaired = run(tmp_path, monkeypatch, operation, [source])
+ assert_endpoints(tmp_path, "Types::Original")
+ assert normalized(repaired) == normalized(initial)
+ # Recovery uses the existing update envelope (without first-scan inventory),
+ # but every persisted source node/edge above must match the accepted graph.
+ repaired_bytes = (out / "graph.json").read_bytes()
+ assert normalized(run(tmp_path, monkeypatch, operation, [source])) == normalized(initial)
+ assert repaired_bytes == (out / "graph.json").read_bytes()
+
+
+@pytest.mark.parametrize("alias", ["using Sender = Types::Other;", "typedef Types::Other Sender;",
+ "typedef Types::Other First, Sender;",
+ "#if ENABLED\nusing Sender = Types::Other;\n#endif"])
+@pytest.mark.parametrize("header_name", ["types.hpp", "alias.hpp"])
+def test_accepted_header_alias_cannot_resolve_outer_global_native_or_qml_endpoint(
+ tmp_path, monkeypatch, alias, header_name
+):
+ """Includes admit alias shadow evidence, even when a header has no Qt token.
+
+ Cross-unit aliases are conservatively unavailable. Their declaration must
+ still block wrong global registration, connection and emission endpoints.
+ """
+ source = project(tmp_path, alias="")
+ header = tmp_path / header_name
+ existing = header.read_bytes() if header.exists() else b""
+ header.write_bytes(existing + ("namespace Bridge {\n" + alias + "\n}\n").encode())
+ if header_name != "types.hpp":
+ source.write_bytes(b'#include "alias.hpp"\n' + source.read_bytes())
+ setup(tmp_path, monkeypatch)
+ run(tmp_path, monkeypatch, "watch")
+ graph = load_graph(tmp_path / "graphify-out/graph.json")
+ wrong, md = member(graph, "Sender", "changed")
+ assert facts(graph, "registration")[0][1].get("class_id") != md["class_id"]
+ assert facts(graph, "connect")[0][1].get("signal_target_id") != wrong
+ assert facts(graph, "emission")[0][1].get("target_id") != wrong
+ assert not [data for _, _, data in graph.edges(data=True)
+ if data.get("context") in {"qt_connect_signal", "qt_signal_emit", "qml_signal_subscription"}]
+
+
+def test_transitive_header_alias_owns_shadow_but_not_prior_source_use(tmp_path, monkeypatch):
+ """Accepted include chains preserve order; projection never expands the corpus."""
+ source = project(tmp_path, alias="")
+ (tmp_path / "alias.hpp").write_text("namespace Bridge { using Sender = Types::Other; }\n", encoding="utf-8")
+ (tmp_path / "wrapper.hpp").write_text('#include "alias.hpp"\n', encoding="utf-8")
+ original = source.read_bytes()
+ source.write_bytes(original + b'#include "wrapper.hpp"\n')
+ setup(tmp_path, monkeypatch)
+ run(tmp_path, monkeypatch, "watch")
+ assert_endpoints(tmp_path, "Sender")
+ source.write_bytes(b'#include "wrapper.hpp"\n' + original)
+ updated = run(tmp_path, monkeypatch, "watch", [source])
+ assert facts(updated, "registration")[0][1]["reason"] == "native_type_included_alias_unavailable"
+ assert not facts(updated, "emission")[0][1].get("target_id")
+ assert normalized(updated) == normalized(clean(tmp_path, tmp_path / ".include-cache"))
+ header = tmp_path / "alias.hpp"
+ header.write_text("// The alias has been removed.\n", encoding="utf-8")
+ refreshed = run(tmp_path, monkeypatch, "watch", [header])
+ assert_endpoints(tmp_path, "Sender")
+ assert normalized(refreshed) == normalized(clean(tmp_path, tmp_path / ".header-edit-cache"))
+
+
+@pytest.mark.parametrize("field,value", [("_origin", "foreign"), ("source_location", "L999")])
+def test_included_alias_fact_corruption_rejects_provenance_instead_of_resolving_outer_type(
+ tmp_path, monkeypatch, field, value
+):
+ """A real published alias occurrence is immutable input; corrupt authority fails closed."""
+ from copy import deepcopy
+ from graphify.extractors.qt_cpp_type_aliases import IncludedAliasShadows
+ from graphify.extractors.qt_cpp_syntax import read_cpp
+ source = project(tmp_path, alias="")
+ with (tmp_path / "types.hpp").open("ab") as header:
+ header.write(b"namespace Bridge { using Sender = Types::Other; }\n")
+ setup(tmp_path, monkeypatch)
+ graph = run(tmp_path, monkeypatch, "watch")
+ aliases = [deepcopy(data) for _, data in graph.nodes(data=True) if qt_metadata(data).get("kind") == "type_alias"]
+ assert len(aliases) == 1
+ aliases[0][field] = value
+ with pytest.raises(ValueError, match="QT_METADATA"):
+ IncludedAliasShadows(read_cpp(source, tmp_path), aliases)
+
+
+def test_fresh_alias_header_uses_borrowed_qt_context_without_mutating_it(tmp_path, monkeypatch):
+ """The facade publishes fresh alias facts even when only the header changes."""
+ from copy import deepcopy
+ from graphify.extract import extract
+ project(tmp_path)
+ setup(tmp_path, monkeypatch)
+ graph = run(tmp_path, monkeypatch, "watch")
+ context = [dict(data, id=identity) for identity, data in graph.nodes(data=True)
+ if data.get("source_file") == "types.hpp"]
+ before = deepcopy(context)
+ header = tmp_path / "alias.hpp"
+ header.write_text("namespace Bridge { using Sender = Types::Other; }\n", encoding="utf-8")
+ result = extract([header], root=tmp_path, parallel=False, refresh_native=True,
+ resolution_context_nodes=context)
+ assert not result["qml_failures"] and not result.get("qt_failures")
+ assert len([node for node in result["nodes"] if qt_metadata(node).get("kind") == "type_alias"]) == 1
+ assert context == before
diff --git a/tests/test_qt_native_notify_updates.py b/tests/test_qt_native_notify_updates.py
new file mode 100644
index 0000000000..9159e93ed8
--- /dev/null
+++ b/tests/test_qt_native_notify_updates.py
@@ -0,0 +1,202 @@
+"""REQ-QML-007/008/017: native notification refresh and durable recovery."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.affected import affected_nodes, load_graph
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import collect_files, extract
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qml_resolution_types import qml_metadata
+from tests.qt_consumer_helpers import cli as consumer_cli
+from tests.test_qt_final_incremental_parity import normalized, run, unrelated
+from tests.test_qt_loader_updates import products
+from tests.test_qt_native_property_notify import sources
+
+
+def fixture(root, *, directed=False):
+ """Only literal declarations/providers participate; unrelated Python stays fixed."""
+ files = sources(parameters="int count")
+ files["Main.qml"] = ('import QtQml\nimport Public.Tools 1.0\n'
+ 'Backend { onCountChanged: (renamed) => { count; renamed; } }\n')
+ files["keep.py"] = 'def helper(): return 7\n\ndef retained(): return helper()\n'
+ for name, source in files.items():
+ (root / name).write_bytes(source.encode())
+ if directed:
+ output = root / "graphify-out"
+ output.mkdir()
+ # Seed through real analysis/export; update inherits this accepted direction.
+ assert to_json(cold(root, root / ".seed", directed=True), {}, str(output / "graph.json"), force=True)
+ return root / "backend.h"
+
+
+def cold(root, cache, *, directed=False):
+ """A separate real facade/build/export baseline checks every public fact."""
+ result = extract(collect_files(root, root=root), root=root, cache_root=cache, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ graph = build_from_json(result, root=root, directed=directed)
+ output = root / "graphify-out"
+ output.mkdir(exist_ok=True)
+ path = output / "notify-comparison.json"
+ assert to_json(graph, {}, str(path), force=True)
+ return load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+
+
+def subscription(graph, *, expected: str | None = "updated"):
+ """A handler owns a reference to the actual signal, never delivery/call inference."""
+ handlers = {identity: qml_metadata(data) for identity, data in graph.nodes(data=True)
+ if qml_metadata(data).get("kind") == "handler"}
+ edges = [(data.get("_src", a), data.get("_tgt", b), data) for a, b, data in graph.edges(data=True)
+ if data.get("context") == "qml_signal_subscription"]
+ assert len(handlers) == 1
+ if expected is None:
+ assert not edges and all(md["status"] != "resolved" and not md["resolved_target_id"] for md in handlers.values())
+ return handlers, edges
+ assert len(edges) == 1
+ source, target, edge = edges[0]
+ assert source in handlers and handlers[source]["resolved_target_id"] == target
+ assert handlers[source]["status"] == "resolved" and edge["relation"] == "references"
+ assert graph.nodes[target]["label"].lstrip(".") == expected + "()"
+ proof = qt_metadata(edge)["native_endpoint"]
+ assert proof["kind"] == "signal" and proof["canonical_target_id"] == target
+ assert graph.nodes[target]["source_file"] == "backend.h"
+ assert graph.has_edge(source, target)
+ if graph.is_directed():
+ assert not graph.has_edge(target, source)
+ assert not any(data.get("relation") == "calls" for a, b, data in graph.edges(data=True)
+ if data.get("_src", a) == source)
+ return handlers, edges
+
+
+@pytest.mark.parametrize("mode", ["manual", "watch"])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml017_ac04_notify_edits_remove_stale_edges_with_full_parity(tmp_path, monkeypatch, capsys, mode, directed):
+ """C++/QML/provider-only edits, removal and repeated refresh preserve source facts."""
+ header = fixture(tmp_path, directed=directed)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, mode)
+ assert initial.is_directed() is directed
+ assert normalized(initial) == normalized(cold(tmp_path, tmp_path / ".cold", directed=directed))
+ assert normalized(initial) == normalized(cold(tmp_path, tmp_path / ".cold", directed=directed))
+ original_handlers, old_links = subscription(initial)
+ old_target, untouched = old_links[0][1], unrelated(initial)
+ valid = header.read_bytes()
+ header.write_bytes(valid.replace(b"updated", b"revised"))
+ renamed = run(tmp_path, monkeypatch, mode, [header])
+ handlers, edges = subscription(renamed, expected="revised")
+ assert set(handlers) == set(original_handlers) and old_target not in renamed
+ assert normalized(renamed) == normalized(cold(tmp_path, tmp_path / ".renamed", directed=directed))
+ output = tmp_path / "graphify-out/graph.json"
+ source, target, _ = edges[0]
+ assert "Main.qml" in consumer_cli(monkeypatch, capsys, output, "explain", source)
+ assert "revised" in consumer_cli(monkeypatch, capsys, output, "query", source)
+ assert source in {hit.node_id for hit in affected_nodes(load_graph(output), target, relations=["references"], depth=1)}
+ header.write_bytes(valid)
+ assert normalized(run(tmp_path, monkeypatch, mode, [header])) == normalized(initial)
+
+ qml = tmp_path / "Main.qml"
+ original_qml = qml.read_bytes()
+ qml.write_bytes(original_qml.replace(b"onCountChanged", b"onUnknownChanged"))
+ changed = run(tmp_path, monkeypatch, mode, [qml])
+ subscription(changed, expected=None)
+ assert normalized(changed) == normalized(cold(tmp_path, tmp_path / ".qml", directed=directed))
+ qml.write_bytes(original_qml.replace(b"(renamed) => { count; renamed; }", b"{ count; }"))
+ injected = run(tmp_path, monkeypatch, mode, [qml])
+ subscription(injected)
+ reads = [qml_metadata(data) for _, data in injected.nodes(data=True)
+ if qml_metadata(data).get("kind") == "read" and qml_metadata(data).get("reference") == "count"]
+ assert len(reads) == 1 and reads[0]["status"] == "dynamic" and reads[0]["reason"] == "javascript_lexical_binding"
+ assert normalized(injected) == normalized(cold(tmp_path, tmp_path / ".legacy", directed=directed))
+ qml.write_bytes(original_qml)
+ subscription(run(tmp_path, monkeypatch, mode, [qml]))
+
+ provider = tmp_path / "tools.pro"
+ original_provider = provider.read_bytes()
+ provider.write_bytes(original_provider.replace(b"Public.Tools", b"Public.Other"))
+ missing = run(tmp_path, monkeypatch, mode, [provider])
+ subscription(missing, expected=None)
+ assert normalized(missing) == normalized(cold(tmp_path, tmp_path / ".provider", directed=directed))
+ assert header.read_bytes() == valid
+ provider.write_bytes(original_provider)
+ subscription(run(tmp_path, monkeypatch, mode, [provider]))
+
+ header.write_bytes(valid.replace(b"NOTIFY updated", b""))
+ removed = run(tmp_path, monkeypatch, mode, [header])
+ subscription(removed, expected=None)
+ assert normalized(removed) == normalized(cold(tmp_path, tmp_path / ".removed", directed=directed))
+ assert unrelated(removed) == untouched
+ durable = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(removed)
+ assert products(tmp_path) == durable
+
+
+@pytest.mark.parametrize("mode", ["manual", "force", "watch"])
+@pytest.mark.parametrize("input_file", ["backend.h", "Main.qml"])
+def test_req_qml017_ac04_notify_parse_failure_retains_products_and_recovers(tmp_path, monkeypatch, mode, input_file):
+ """Actual malformed native/QML input preserves all four accepted durable products."""
+ fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ normal = "manual" if mode == "force" else mode
+ initial = run(tmp_path, monkeypatch, normal)
+ subscription(initial)
+ path = tmp_path / input_file
+ original, durable = path.read_bytes(), products(tmp_path)
+ path.write_bytes(original + (b"\r\nvoid broken({" if input_file.endswith(".h") else b"\r\nQtObject {"))
+ if mode == "watch":
+ from graphify.watch import _rebuild_code
+ assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True)
+ else:
+ with pytest.raises(SystemExit) as failure:
+ if mode == "force":
+ from tests.test_qt_cpp_upgrade_invalidation import cli
+ cli(tmp_path, monkeypatch, "update", force=True)
+ else:
+ run(tmp_path, monkeypatch, normal, [path])
+ assert failure.value.code == 1
+ assert products(tmp_path) == durable
+ path.write_bytes(original)
+ assert normalized(run(tmp_path, monkeypatch, normal, [path])) == normalized(initial)
+ repaired = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, normal, [])) == normalized(initial)
+ assert products(tmp_path) == repaired
+
+
+@pytest.mark.parametrize("mode", ["manual", "watch"])
+def test_req_qml017_ac04_notify_manifest_failure_rolls_back_and_retries(tmp_path, monkeypatch, mode):
+ """A real post-graph manifest replacement failure rolls the entire cohort back."""
+ import graphify.watch as watch
+ header = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, mode)
+ subscription(initial)
+ durable = products(tmp_path)
+ header.write_bytes(header.read_bytes().replace(b"updated", b"revised"))
+ replace, attempts = watch.os_replace_with_fallback, []
+
+ def fail_once(start, destination):
+ if str(destination).endswith("manifest.json") and not attempts:
+ attempts.append(str(destination))
+ raise PermissionError("injected native-notify manifest replacement failure")
+ return replace(start, destination)
+
+ with monkeypatch.context() as failure:
+ failure.setattr(watch, "os_replace_with_fallback", fail_once)
+ if mode == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failure, mode, [header])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(tmp_path, changed_paths=[header], no_cluster=True)
+ assert attempts and products(tmp_path) == durable
+ recovered = run(tmp_path, monkeypatch, mode, [header])
+ subscription(recovered, expected="revised")
+ assert normalized(recovered) != normalized(initial)
+ assert normalized(recovered) == normalized(cold(tmp_path, tmp_path / ".retry"))
+ repaired = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(recovered)
+ assert products(tmp_path) == repaired
diff --git a/tests/test_qt_native_project_integration.py b/tests/test_qt_native_project_integration.py
new file mode 100644
index 0000000000..decdc559b7
--- /dev/null
+++ b/tests/test_qt_native_project_integration.py
@@ -0,0 +1,66 @@
+"""Production build metadata supplies exact native QML exposure context."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis
+
+
+HEADER = '''#include
+class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service)
+ Q_PROPERTY(int value READ value)
+public: int value() const { return 1; }
+ Q_INVOKABLE int fetch() { return 2; }
+};'''
+QML = '''import Public.Tools 1.0 as Tools
+Tools.Service { property int displayed: value; property int called: fetch() }
+'''
+
+
+def native_edges(result):
+ return [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")]
+
+
+@pytest.mark.parametrize("build_name,build", [
+ ("CMakeLists.txt", "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)"),
+ ("app.pro", "CONFIG += qmltypes\nQML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\nHEADERS += backend.h\nDISTFILES += Main.qml"),
+])
+def test_actual_macro_provider_uses_cmake_or_qmake_module_and_exact_cpp_endpoints(tmp_path, build_name, build):
+ result = analysis(tmp_path, {build_name: build, "backend.h": HEADER, "Main.qml": QML})
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ edges = native_edges(result)
+ calls = [edge for edge in edges if edge["relation"] == "calls"]
+ assert len(calls) == 1
+ proof = qt_metadata(calls[0])["native_endpoint"]
+ assert proof["canonical_target_id"] == calls[0]["target"]
+ assert graph.nodes[proof["canonical_target_id"]]["label"].endswith(".fetch()")
+ assert graph.nodes[proof["canonical_target_id"]]["source_file"] == "backend.h"
+ assert graph.nodes[proof["class_id"]]["source_file"] == "backend.h"
+ module_ids = [item for item in proof["evidence"] if qml_metadata(graph.nodes[item]).get("kind") == "qt_module"]
+ assert len(module_ids) == 1
+ module = qml_metadata(graph.nodes[module_ids[0]])
+ assert (module["uri"], module["major"], module["minor"]) == ("Public.Tools", 1, 0)
+ assert graph.nodes[module_ids[0]]["source_file"] == build_name
+ assert any(edge["context"] == "qml_binding_read" for edge in edges)
+ assert all(graph.has_edge(edge["source"], edge["target"]) for edge in edges)
+
+
+def test_same_directory_without_literal_build_source_membership_does_not_expose_class(tmp_path):
+ result = analysis(tmp_path, {"CMakeLists.txt": "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml)",
+ "backend.h": HEADER, "Main.qml": QML})
+ assert not native_edges(result)
+ uses = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "type_use"]
+ assert uses and all(md["status"] != "resolved" for md in uses)
+ assert any(qt_metadata(node).get("kind") == "registration" for node in result["nodes"])
+
+
+def test_cpp_source_in_two_distinct_build_contexts_has_no_arbitrary_native_provider(tmp_path):
+ module = "qt_add_qml_module({target} URI Public.Tools VERSION 1.0 SOURCES backend.h)"
+ result = analysis(tmp_path, {"CMakeLists.txt": module.format(target="one"),
+ "duplicate.cmake": module.format(target="two"), "backend.h": HEADER, "Main.qml": QML})
+ assert not native_edges(result)
+ imports = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "import_resolution"]
+ assert imports and all(md["status"] == "ambiguous" for md in imports)
diff --git a/tests/test_qt_native_property_notify.py b/tests/test_qt_native_property_notify.py
new file mode 100644
index 0000000000..61efc3e7c4
--- /dev/null
+++ b/tests/test_qt_native_property_notify.py
@@ -0,0 +1,233 @@
+"""REQ-QML-007/008/017: native property handlers use the actual NOTIFY signal."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qml_resolution_types import qml_metadata
+from tests.qt_analysis_helpers import analysis
+
+
+def sources(*, notify="updated", parameters="", attribute="", handlers="normal", signal_role="signals", extra="", module=True):
+ """Qmake owns registration; source declarations own property/accessor/signal proof."""
+ clause = "NOTIFY " + notify if notify else ""
+ header = f'''// Original bytes: café
+class Backend : public QObject {{ Q_OBJECT QML_ELEMENT
+ Q_PROPERTY(int count READ count {clause} {attribute})
+ {extra}
+ public: int count() const;
+ {signal_role}: void {notify or 'updated'}({parameters});
+}};
+'''
+ if handlers == "normal":
+ body = 'Backend { id: backend; onCountChanged: { count; } }'
+ elif handlers == "connections":
+ body = ('Backend { id: backend; property Connections observer: Connections { '
+ 'target: backend; function onCountChanged() { count; } } }')
+ else:
+ body = ('Backend { id: backend; onCountChanged: {}; onTotalChanged: {} }')
+ files = {"backend.h": "\ufeff" + header.replace("\n", "\r\n"),
+ "Main.qml": 'import QtQml\nimport Public.Tools 1.0\n' + body + '\n'}
+ if module:
+ files["tools.pro"] = 'QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.0\nHEADERS += backend.h\nQML_FILES += Main.qml\n'
+ return files
+
+
+def persisted(root, files, *, directed=False):
+ """Cross-language endpoint proof survives actual graph publication and reload."""
+ result = analysis(root, files)
+ graph = build_from_json(result, root=root, directed=directed)
+ output = root / "notify.json"
+ assert to_json(graph, {}, str(output), force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ restored = load_node_link_graph(payload)
+ handlers = {node["id"]: node for node in result["nodes"] if qml_metadata(node).get("kind") == "handler"}
+ links = [edge for edge in payload["links"] if edge["source"] in handlers and edge.get("context") == "qml_signal_subscription"]
+ return result, restored, handlers, links, output
+
+
+def assert_notify(result, graph, handlers, links, expected="updated"):
+ """Each handler references the exact canonical signal with source-owned evidence."""
+ assert handlers and len(links) == len(handlers)
+ native_members = {node["id"]: node for node in result["nodes"] if qt_metadata(node).get("kind") == "member"}
+ for edge in links:
+ site, target = handlers[edge["source"]], graph.nodes[edge["target"]]
+ md, proof = qml_metadata(site), qt_metadata(edge)["native_endpoint"]
+ assert md["status"] == "resolved" and md["resolved_target_id"] == edge["target"]
+ assert target["source_file"] == "backend.h" and target["label"].lstrip(".") == expected + "()"
+ assert edge["relation"] == "references" and edge["confidence"] == "INFERRED"
+ assert graph.has_edge(edge["source"], edge["target"])
+ if graph.is_directed():
+ assert not graph.has_edge(edge["target"], edge["source"])
+ else:
+ assert (graph.edges[edge["source"], edge["target"]]["_src"],
+ graph.edges[edge["source"], edge["target"]]["_tgt"]) == (edge["source"], edge["target"])
+ assert proof["kind"] == "signal" and proof["canonical_target_id"] == edge["target"]
+ member = native_members[proof["member_fact_id"]]
+ assert qt_metadata(member)["raw_name"] == expected and "signal" in qt_metadata(member)["roles"]
+ if md.get("reason") == "native_property_notify_signal":
+ evidence = [qt_metadata(node) for node in result["nodes"] if node["id"] in proof["evidence"]]
+ assert any(item.get("kind") == "property" for item in evidence)
+ assert any(item.get("kind") == "property_accessor" and item.get("accessor_role") == "notify" for item in evidence)
+ assert edge["source_file"] == "Main.qml" and edge["source_location"] == site["source_location"]
+ span = md["span"]
+ original = next(node for node in result["nodes"] if node["id"] == site["id"])
+ assert original["source_location"].startswith("L") and qt_metadata(edge)["span"] == span
+
+
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("handlers", ["normal", "connections"])
+@pytest.mark.parametrize("notify", ["updated", "countChanged"])
+def test_req_qml007_ac03_native_property_handler_maps_custom_and_conventional_notify(tmp_path, directed, handlers, notify):
+ """QML's handler spelling is property-based even when C++ signal differs."""
+ result, graph, sites, edges, _ = persisted(tmp_path, sources(notify=notify, handlers=handlers), directed=directed)
+ assert_notify(result, graph, sites, edges, notify)
+ original = (tmp_path / "backend.h").read_bytes()
+ prop = next(qt_metadata(node) for node in result["nodes"] if qt_metadata(node).get("kind") == "property")
+ span = prop["span"]
+ assert original[span["start_byte"]:span["end_byte"]] == f"Q_PROPERTY(int count READ count NOTIFY {notify} )".encode()
+ signal = next(qt_metadata(node) for node in result["nodes"]
+ if qt_metadata(node).get("kind") == "member" and qt_metadata(node).get("raw_name") == notify)
+ member_span = signal["span"]
+ assert notify.encode() + b"(" in original[member_span["start_byte"]:member_span["end_byte"]]
+
+
+def test_req_qml008_ac02_shared_notify_preserves_two_independent_handler_sites(tmp_path):
+ """Shared NOTIFY signals do not collapse the two source handler occurrences."""
+ extra = 'Q_PROPERTY(int total READ total NOTIFY updated) public: int total() const;'
+ result, graph, sites, edges, _ = persisted(tmp_path, sources(extra=extra, handlers="shared"))
+ assert_notify(result, graph, sites, edges)
+ assert len(edges) == 2 and len({edge["source"] for edge in edges}) == 2
+ assert len({edge["target"] for edge in edges}) == 1
+
+
+@pytest.mark.parametrize("options", [
+ {"notify": ""}, {"notify": "", "attribute": "CONSTANT"},
+ {"attribute": "CONSTANT"}, {"signal_role": "public"},
+ {"parameters": "QString changed"}, {"attribute": "REVISION 1"},
+ {"module": False},
+])
+def test_req_qml008_ac03_invalid_or_unexposed_notify_cannot_create_subscription(tmp_path, options):
+ """Missing notification, ordinary methods, type/version/provider gaps stay explicit."""
+ _, _, sites, edges, _ = persisted(tmp_path, sources(**options))
+ assert sites and not edges
+ assert all(qml_metadata(site)["status"] != "resolved" and not qml_metadata(site).get("resolved_target_id") for site in sites.values())
+
+
+@pytest.mark.parametrize("parameters", ["", "int count"])
+def test_req_qml007_ac03_notify_parameter_binding_uses_actual_signal_signature(tmp_path, parameters):
+ """A signal parameter shadows a same-named native property in the handler body."""
+ result, graph, handlers, links, _ = persisted(tmp_path, sources(parameters=parameters))
+ assert_notify(result, graph, handlers, links)
+ reads = [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == "read"
+ and qml_metadata(node).get("reference") == "count"]
+ assert len(reads) == 1
+ assert reads[0]["status"] == ("dynamic" if parameters else "resolved")
+ if parameters:
+ assert reads[0]["reason"] == "javascript_lexical_binding" and not reads[0]["resolved_target_id"]
+
+
+def test_req_qml017_ac04_native_notify_query_and_affected_use_canonical_signal(tmp_path, monkeypatch, capsys):
+ """Consumers retain the canonical endpoint and QML occurrence direction."""
+ from graphify.affected import affected_nodes, load_graph
+ from tests.qt_consumer_helpers import cli
+ result, graph, sites, links, output = persisted(tmp_path, sources(), directed=True)
+ assert_notify(result, graph, sites, links)
+ source, target = links[0]["source"], links[0]["target"]
+ assert "Main.qml" in cli(monkeypatch, capsys, output, "explain", source)
+ assert "updated" in cli(monkeypatch, capsys, output, "query", source)
+ assert source in {hit.node_id for hit in affected_nodes(load_graph(output), target, relations=["references"], depth=1)}
+
+
+@pytest.mark.parametrize("damage", ["overload", "duplicate_property", "private_signal", "conditional"])
+def test_req_qml008_ac03_ambiguous_private_or_conditional_native_notify_is_not_guessed(tmp_path, damage):
+ """A plausible spelling does not replace exact accessor/public-signal authority."""
+ files = sources()
+ if damage == "overload":
+ files["backend.h"] = files["backend.h"].replace('void updated();', 'void updated(); void updated(int count);')
+ elif damage == "duplicate_property":
+ files["backend.h"] = files["backend.h"].replace('Q_PROPERTY(int count READ count NOTIFY updated )',
+ 'Q_PROPERTY(int count READ count NOTIFY updated ) Q_PROPERTY(int count READ count NOTIFY updated)')
+ elif damage == "private_signal":
+ files["backend.h"] = files["backend.h"].replace('signals: void updated();', 'private: Q_SIGNAL void updated();')
+ else:
+ files["backend.h"] = files["backend.h"].replace(' Q_PROPERTY', '\r\n#if FEATURE\r\n Q_PROPERTY').replace(
+ 'count READ count NOTIFY updated )', 'count READ count NOTIFY updated )\r\n#endif\r\n')
+ _, _, handlers, edges, _ = persisted(tmp_path, files)
+ assert handlers and not edges and all(qml_metadata(node)["status"] != "resolved" for node in handlers.values())
+
+
+def test_req_qml008_ac02_qml_inheritance_uses_native_notify_without_cpp_ancestry_guess(tmp_path):
+ """The supported source QML base chain retains its exact native provider."""
+ files = sources()
+ files["Base.qml"] = 'import Public.Tools 1.0\nBackend {}\n'
+ files["Main.qml"] = 'import QtQml\nBase { onCountChanged: count }\n'
+ files["tools.pro"] += 'QML_FILES += Base.qml\n'
+ result, graph, handlers, edges, _ = persisted(tmp_path, files)
+ assert_notify(result, graph, handlers, edges)
+
+
+def test_req_qml008_ac03_unproved_native_inherited_property_cannot_invent_notify(tmp_path):
+ """C++ ancestor lookup stays outside the current native-member bounded profile."""
+ files = sources()
+ files["backend.h"] = files["backend.h"].replace('class Backend', 'class Base').replace('QML_ELEMENT', '')
+ files["backend.h"] += 'class Backend : public Base { Q_OBJECT QML_ELEMENT };\r\n'
+ _, _, handlers, edges, _ = persisted(tmp_path, files)
+ assert handlers and not edges
+
+
+@pytest.mark.parametrize("damage", ["drop_accessor", "wrong_owner", "wrong_span", "wrong_target"])
+def test_req_qml008_ac03_notify_join_requires_accepted_accessor_identity(tmp_path, damage):
+ """A direct index join must reject corrupted/missing provenance, without reads."""
+ import copy
+ from graphify.extractors.qt_cpp_facts import update_qt
+ from graphify.qt_project_index import QtProjectIndex
+ from graphify.qt_qml_bridge import build_qt_qml_bridge
+ result = analysis(tmp_path, sources())
+ borrowed = copy.deepcopy(result)
+ nodes = copy.deepcopy(result["nodes"])
+ accessor = next(node for node in nodes if qt_metadata(node).get("kind") == "property_accessor"
+ and qt_metadata(node).get("accessor_role") == "notify")
+ if damage == "drop_accessor":
+ nodes.remove(accessor)
+ elif damage == "wrong_owner":
+ update_qt(accessor, owner_id="unrelated")
+ elif damage == "wrong_span":
+ update_qt(accessor, span={**qt_metadata(accessor)["span"], "start_byte": 0})
+ else:
+ update_qt(accessor, generic_target_id="unrelated")
+ bridge = build_qt_qml_bridge(nodes, result["edges"], root=tmp_path,
+ project_index=QtProjectIndex(nodes, result["edges"], root=tmp_path))
+ provider = next(iter(bridge.provider_records))
+ property_id = next(node["id"] for node in nodes if qt_metadata(node).get("kind") == "property")
+ assert bridge.property_notify(property_id, provider).status != "resolved"
+ assert result == borrowed
+
+
+@pytest.mark.parametrize("style", ["legacy", "arrow", "anonymous", "connections"])
+def test_req_qml007_ac03_notify_binds_only_block_injection_or_explicit_formals(tmp_path, style):
+ """Signal parameter names do not shadow a function's outer property dependency."""
+ files = sources(parameters="int count")
+ if style == "legacy":
+ handler = 'onCountChanged: { count; }'
+ elif style == "arrow":
+ handler = 'onCountChanged: (renamed) => { count; renamed; }'
+ elif style == "anonymous":
+ handler = 'onCountChanged: function(renamed) { count; renamed; }'
+ else:
+ handler = ('property Connections observer: Connections { target: backend; '
+ 'function onCountChanged(renamed) { count; renamed; } }')
+ files["Main.qml"] = 'import QtQml\nimport Public.Tools 1.0\nBackend { id: backend; ' + handler + ' }\n'
+ result, graph, handlers, edges, _ = persisted(tmp_path, files)
+ assert_notify(result, graph, handlers, edges)
+ reads = {qml_metadata(node)["reference"]: qml_metadata(node) for node in result["nodes"]
+ if qml_metadata(node).get("kind") == "read"}
+ assert reads["count"]["status"] == ("dynamic" if style == "legacy" else "resolved")
+ if style != "legacy":
+ assert reads["renamed"]["status"] == "dynamic" and reads["renamed"]["reason"] == "javascript_lexical_binding"
+ assert qml_metadata(next(iter(handlers.values())))["implicit_parameters"] is (style == "legacy")
diff --git a/tests/test_qt_nearby_alias_fixture.py b/tests/test_qt_nearby_alias_fixture.py
new file mode 100644
index 0000000000..5977e4df1c
--- /dev/null
+++ b/tests/test_qt_nearby_alias_fixture.py
@@ -0,0 +1,59 @@
+"""REQ-QML-020: real multi-ancestor short paths retain a nearby alias scenario."""
+from __future__ import annotations
+
+import ctypes
+import os
+from pathlib import Path
+import tempfile
+
+import pytest
+
+from tests.test_qt_facade_path_aliases import (
+ GENERIC, assert_sources, graph, nearby_parent_alias, normalized, write_generic,
+)
+
+
+@pytest.fixture(autouse=True)
+def many_short_ancestors(tmp_path, monkeypatch):
+ """Provide actual long TEMP ancestors before the shared alias fixture runs."""
+ if os.name != "nt":
+ pytest.skip("Windows short-path API is not applicable on this host")
+ parent = tmp_path.resolve()
+ for index in range(5):
+ parent /= f"graphify long ancestor {index}"
+ parent.mkdir()
+ monkeypatch.setattr(tempfile, "tempdir", str(parent))
+
+
+@pytest.mark.parametrize("nearby_parent_alias", ["windows_short"], indirect=True)
+def test_req_qml020_ac01_real_short_ancestors_preserve_nearby_facade_provenance(
+ nearby_parent_alias):
+ """The real fixture admits one short basename, then cold/warm facts stay equal."""
+ parent, alias = nearby_parent_alias
+ api = ctypes.windll.kernel32.GetShortPathNameW
+ api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
+ api.restype = ctypes.c_uint32
+ buffer = ctypes.create_unicode_buffer(32768)
+ length = api(str(parent), buffer, len(buffer))
+ assert 0 < length < len(buffer)
+ full_short = Path(buffer.value)
+ full_relative = Path(os.path.relpath(full_short, parent))
+ if sum(part == ".." for part in full_relative.parts) <= 3:
+ pytest.skip("Filesystem does not expose multiple ancestor short-path aliases")
+ assert full_short.resolve() == parent and alias.resolve() == parent
+ nearby_relative = Path(os.path.relpath(alias, parent))
+ assert sum(part == ".." for part in nearby_relative.parts) == 1
+ assert alias.parent == parent.parent and alias.name == full_short.name
+
+ # Exercise the same production interface as the original hosted regression;
+ # reanchoring the fixture must not hide cold/warm source or edge corruption.
+ canonical, supplied = parent / "native-profile", alias / "native-profile"
+ canonical.mkdir()
+ write_generic(canonical)
+ expected, _ = graph(canonical, canonical, parent / "canonical-cache", GENERIC)
+ cold, cold_result = graph(supplied, supplied, parent / "alias-cache", GENERIC)
+ warm, warm_result = graph(supplied, supplied, parent / "alias-cache", GENERIC)
+ assert_sources(cold_result, GENERIC)
+ assert_sources(warm_result, GENERIC)
+ assert normalized(cold) == normalized(warm) == normalized(expected)
+ assert GENERIC == {name: (canonical / name).read_bytes() for name in GENERIC}
diff --git a/tests/test_qt_orphan_cleanup.py b/tests/test_qt_orphan_cleanup.py
new file mode 100644
index 0000000000..9dbbe6e90d
--- /dev/null
+++ b/tests/test_qt_orphan_cleanup.py
@@ -0,0 +1,94 @@
+"""REQ-QML-011-AC04/REQ-QML-017-AC04: accepted full refresh retires stale stubs."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.qt_orphan_cleanup import prune_stale_ast_orphans
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+from tests.test_qt_reflection_updates import cpp_source, fixture
+
+
+@pytest.mark.parametrize("operation", ["invoke", "static_read", "static_write", "property_handle"])
+def test_req_qml017_ac04_watch_restored_sdk_source_has_no_stale_generic_placeholder(tmp_path, monkeypatch, operation):
+ """An actual source class introduces external type stubs, then is removed."""
+ cpp = fixture(tmp_path, operation)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, "watch")
+ valid, untouched = cpp.read_bytes(), unrelated(initial)
+ cpp.write_bytes(cpp_source(tmp_path, operation, "local_alias"))
+ shadow = run(tmp_path, monkeypatch, "watch", [cpp])
+ assert "qobject" in shadow
+ cpp.write_bytes(valid)
+ restored = run(tmp_path, monkeypatch, "watch", [cpp])
+ assert "qobject" not in restored, "Removed source declaration left its unreferenced generic AST stub"
+ assert normalized(restored) == normalized(initial) == normalized(clean(tmp_path, tmp_path / ".restored-cold"))
+ assert unrelated(restored) == untouched
+
+
+def payload():
+ """Prior accepted graph roles differ even when their source path is empty."""
+ base = {"file_type": "code", "_origin": "ast", "source_file": "", "source_location": ""}
+ nodes: list[dict] = [{**base, "id": name, "label": name} for name in
+ ("stale", "fresh", "edge_target", "hyper_target", "typed", "source", "semantic", "callable", "metadata")]
+ by_id = {node["id"]: node for node in nodes}
+ by_id["typed"]["type"] = "class"
+ by_id["source"]["source_file"] = "keep.cpp"
+ by_id["semantic"]["_origin"] = "semantic"
+ by_id["callable"]["_callable"] = True
+ by_id["metadata"]["metadata"] = {"qt": {"contract_version": 1, "kind": "class"}}
+ return {"nodes": nodes, "edges": [{"source": "semantic", "target": "edge_target"}],
+ "hyperedges": [{"nodes": ["semantic", "hyper_target"]}], "input_tokens": 0}
+
+
+def test_req_qml011_ac04_cleanup_preserves_authority_references_and_borrowed_dictionaries():
+ """Only stale generic placeholders are retired, without in-place mutation."""
+ original = payload()
+ borrowed = copy.deepcopy(original)
+ candidate = prune_stale_ast_orphans(original, fresh_ids={"fresh"}, complete_refresh=True)
+ assert {node["id"] for node in candidate["nodes"]} == {node["id"] for node in original["nodes"]} - {"stale"}
+ assert original == borrowed
+ assert candidate["edges"] == original["edges"] and candidate["hyperedges"] == original["hyperedges"]
+
+
+@pytest.mark.parametrize("field", ["nodes", "members", "node_ids"])
+def test_req_qml011_ac04_hyperedge_member_aliases_protect_source_less_ast_nodes(field):
+ """All existing hyperedge membership spellings protect their accepted target."""
+ original = payload()
+ original["hyperedges"] = [{field: ["stale"]}]
+ assert "stale" in {node["id"] for node in prune_stale_ast_orphans(original, fresh_ids=set(), complete_refresh=True)["nodes"]}
+
+
+def test_req_qml011_ac04_partial_refresh_is_not_authority_to_remove_placeholder():
+ original = payload()
+ assert prune_stale_ast_orphans(original, fresh_ids=set(), complete_refresh=False) is original
+
+
+@pytest.mark.parametrize("mode", ["manual", "watch", "watch_policy"])
+def test_req_qml011_ac04_complete_refresh_keeps_semantic_connected_and_hyperedge_stub_context(tmp_path, monkeypatch, mode):
+ """The production reconcile path retains facts that fresh code cannot recreate."""
+ cpp = fixture(tmp_path, "invoke")
+ monkeypatch.chdir(tmp_path)
+ run(tmp_path, monkeypatch, "watch")
+ cpp.write_bytes(cpp_source(tmp_path, "invoke", "local_alias"))
+ run(tmp_path, monkeypatch, "watch", [cpp])
+ output = tmp_path / "graphify-out/graph.json"
+ previous = json.loads(output.read_text(encoding="utf-8"))
+ role_nodes = payload()["nodes"]
+ retained = [node for node in role_nodes if node["id"] in {"semantic", "edge_target", "hyper_target"}]
+ previous["nodes"].extend(retained)
+ previous["links"].append({"source": "semantic", "target": "edge_target", "relation": "references",
+ "confidence": "INFERRED", "_origin": "semantic", "source_file": ""})
+ previous["hyperedges"] = [{"id": "retained_hyperedge", "nodes": ["semantic", "hyper_target"]}]
+ output.write_text(json.dumps(previous), encoding="utf-8")
+ cpp.write_bytes(cpp_source(tmp_path, "invoke"))
+ if mode == "watch_policy":
+ import graphify.qt_incremental as policy
+ monkeypatch.setattr(policy, "QT_POLICY_VERSION", policy.QT_POLICY_VERSION + 1)
+ restored = run(tmp_path, monkeypatch, "manual" if mode == "manual" else "watch", [cpp])
+ assert "qobject" not in restored
+ assert {"semantic", "edge_target", "hyper_target"} <= set(restored)
+ assert restored.has_edge("semantic", "edge_target")
+ assert json.loads(output.read_text(encoding="utf-8"))["hyperedges"] == previous["hyperedges"]
diff --git a/tests/test_qt_orphan_import_proof.py b/tests/test_qt_orphan_import_proof.py
new file mode 100644
index 0000000000..cdb3abece5
--- /dev/null
+++ b/tests/test_qt_orphan_import_proof.py
@@ -0,0 +1,144 @@
+"""INC-QML-35: accepted prior import identity bounds external orphan retirement."""
+from __future__ import annotations
+
+import copy
+
+import pytest
+
+from graphify.build import _EXTERNAL_STUB_RELATIONS, _load_existing_graph, build_from_json
+from graphify.export import to_json
+from graphify.qt_orphan_cleanup import previous_import_targets, prune_stale_ast_orphans
+from tests.test_qt_cpp_file_roles import cpp_fixture
+
+
+@pytest.fixture
+def prior(tmp_path):
+ """Actual C++ import and builder-created external role survive the normal origin-classifying reload."""
+ graph = build_from_json(cpp_fixture(tmp_path), root=tmp_path)
+ path = tmp_path / "previous.json"
+ assert to_json(graph, {}, str(path))
+ loaded = _load_existing_graph(path)
+ assert loaded is not None
+ nodes, edges, hyperedges, directed = loaded
+ payload = {"nodes": nodes, "links": edges, "hyperedges": hyperedges, "directed": directed}
+ assert next(node for node in nodes if node["id"] == "missing")["_origin"] == "semantic"
+ return payload
+
+
+def targets(prior):
+ return previous_import_targets(prior, import_relations=_EXTERNAL_STUB_RELATIONS)
+
+
+def import_edge(prior):
+ return next(edge for edge in prior["links"] if edge.get("relation") == "imports" and edge["target"] == "missing")
+
+
+def removal(prior, node, *, complete=True, fresh=(), edges=(), hyperedges=()):
+ result = {"nodes": [node], "edges": list(edges), "hyperedges": list(hyperedges)}
+ before = copy.deepcopy(result)
+ candidate = prune_stale_ast_orphans(result, fresh_ids=set(fresh), complete_refresh=complete,
+ prior_import_ids=targets(prior))
+ assert result == before
+ return candidate["nodes"]
+
+
+def test_actual_prior_import_proof_removes_only_unused_external_role_without_mutation(prior):
+ """A generated endpoint needs both source-owned prior identity and absence of every live reference."""
+ before = copy.deepcopy(prior)
+ assert targets(prior) == {"missing"}
+ node = next(node for node in prior["nodes"] if node["id"] == "missing")
+ assert not removal(prior, node)
+ assert prior == before
+ # Persisted presentation properties do not convert a generated endpoint
+ # into a source definition or alter its known import ownership.
+ assert not removal(prior, {**node, "x": 20, "y": 30, "community_name": "Imports"})
+
+
+@pytest.mark.parametrize("corruption", ["semantic", "unstamped", "relation_list", "relation_foreign", "context",
+ "confidence", "source_file", "empty_source", "foreign_owner", "foreign_target",
+ "owner_origin", "owner_source", "owner_role", "duplicate_owner", "duplicate_target",
+ "partial_source", "partial_target", "foreign_marker", "list_marker", "reverse_directed"])
+def test_corrupt_or_foreign_prior_import_has_no_cleanup_authority(prior, corruption):
+ """Borrowed historical facts with missing/conflicting ownership cannot retire an external node."""
+ edge = import_edge(prior)
+ source, target = edge["source"], edge["target"]
+ owner = next(node for node in prior["nodes"] if node["id"] == source)
+ if corruption in {"semantic", "unstamped"}:
+ edge["_origin"] = "semantic" if corruption == "semantic" else None
+ elif corruption == "relation_list":
+ edge["relation"] = ["imports"]
+ elif corruption == "relation_foreign":
+ edge["relation"] = "references"
+ elif corruption == "context":
+ edge["context"] = "qt_context_access"
+ elif corruption == "confidence":
+ edge["confidence"] = "INFERRED"
+ elif corruption in {"source_file", "empty_source"}:
+ edge["source_file"] = "unrelated.cpp" if corruption == "source_file" else ""
+ elif corruption == "foreign_owner":
+ edge["source"] = "absent"
+ elif corruption == "foreign_target":
+ edge["target"] = "absent"
+ elif corruption in {"owner_origin", "owner_source", "owner_role"}:
+ key = {"owner_origin": "_origin", "owner_source": "source_file", "owner_role": "file_type"}[corruption]
+ owner[key] = "semantic" if corruption == "owner_origin" else "unrelated.cpp" if corruption == "owner_source" else "concept"
+ elif corruption in {"duplicate_owner", "duplicate_target"}:
+ identity = source if corruption == "duplicate_owner" else target
+ prior["nodes"].append(copy.deepcopy(next(node for node in prior["nodes"] if node["id"] == identity)))
+ else:
+ edge["_src"], edge["_tgt"] = source, target
+ if corruption == "partial_source":
+ edge.pop("_tgt")
+ elif corruption == "partial_target":
+ edge.pop("_src")
+ elif corruption == "foreign_marker":
+ edge["_src"] = "private-needle\nforged"
+ elif corruption == "list_marker":
+ edge["_tgt"] = [target]
+ else:
+ prior["directed"] = True
+ edge["_src"], edge["_tgt"] = target, source
+ before = copy.deepcopy(prior)
+ assert not targets(prior)
+ node = next(node for node in prior["nodes"] if node["id"] == "missing")
+ assert removal(prior, node) == [node]
+ assert prior == before
+
+
+@pytest.mark.parametrize("field", ["nodes", "links"])
+@pytest.mark.parametrize("value", [None, {}])
+def test_malformed_prior_groups_do_not_supply_proof(prior, field, value):
+ prior[field] = value
+ assert not targets(prior)
+
+
+@pytest.mark.parametrize("role", ["foreign_origin", "ast_origin", "metadata", "empty_metadata", "callable", "type",
+ "source", "location", "label", "identity", "external_type"])
+def test_authoritative_or_noncanonical_external_role_is_preserved(prior, role):
+ """A same-name source/user/unsupported role cannot be erased by a known import target."""
+ node = copy.deepcopy(next(node for node in prior["nodes"] if node["id"] == "missing"))
+ key, value = {"foreign_origin": ("_origin", "authored"), "ast_origin": ("_origin", "ast"),
+ "metadata": ("metadata", {"native_endpoint": "accepted"}), "empty_metadata": ("metadata", {}),
+ "callable": ("_callable", False), "type": ("type", "class"), "source": ("source_file", "kept.cpp"),
+ "location": ("source_location", "L2"), "label": ("label", "authored label"),
+ "identity": ("id", "authored_missing"), "external_type": ("external", 1)}[role]
+ node[key] = value
+ assert removal(prior, node) == [node]
+
+
+@pytest.mark.parametrize("reference", ["partial", "fresh", "ordinary", "nodes", "members", "node_ids"])
+def test_live_reference_or_incomplete_refresh_preserves_import_endpoint(prior, reference):
+ """Every existing hyperedge spelling, an ordinary edge and fresh identity keep the endpoint live."""
+ node = next(node for node in prior["nodes"] if node["id"] == "missing")
+ options = {"complete": reference != "partial", "fresh": ("missing",) if reference == "fresh" else (),
+ "edges": ({"source": "kept", "target": "missing"},) if reference == "ordinary" else (),
+ "hyperedges": ({reference: ["missing"]},) if reference in {"nodes", "members", "node_ids"} else ()}
+ assert removal(prior, node, **options) == [node]
+
+
+def test_complete_undirected_marker_pair_preserves_actual_prior_source_ownership(prior):
+ edge = import_edge(prior)
+ source, target = edge["source"], edge["target"]
+ edge["source"], edge["target"] = target, source
+ edge["_src"], edge["_tgt"] = source, target
+ assert targets(prior) == {"missing"}
diff --git a/tests/test_qt_overload_consumers.py b/tests/test_qt_overload_consumers.py
new file mode 100644
index 0000000000..665b1f83cc
--- /dev/null
+++ b/tests/test_qt_overload_consumers.py
@@ -0,0 +1,100 @@
+"""INC-QML-15: distinct constructor bodies own durable native relationships."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.affected import affected_nodes
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.serve import _query_graph_text
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_cpp_overload_identity import constructors
+
+
+def body(parameter="", signal="changed"):
+ """Literal loader handles and an emission belong to one exact constructor."""
+ return (f"Backend({parameter}) {{ QQmlApplicationEngine engine; engine.load(\"qrc:/App/Main.qml\"); "
+ "QObject *root = engine.rootObjects().first(); root->setProperty(\"value\", 2); "
+ f"emit {signal}(); }}")
+
+
+def fixture(root, *, inline=False, same_line=False, unicode=False):
+ """Public Qt source is analyzed as data; no engine/component or project hook executes."""
+ if inline:
+ header = ("class Backend : public QObject { Q_OBJECT public:\n " + body() + "\n " + body("int value", "otherChanged") +
+ "\nsignals: void changed(); void otherChanged(); };\n")
+ implementation = "int unrelated(){return 7;}\n"
+ else:
+ header = ("class Backend : public QObject { Q_OBJECT public:\n Backend(); Backend(int value);\n"
+ "signals: void changed(); void otherChanged(); };\n")
+ separator = " " if same_line else "\n"
+ implementation = '#include "backend.hpp"\n' + body().replace("Backend(", "Backend::Backend(", 1)
+ implementation += separator + body("int renamed", "otherChanged").replace("Backend(", "Backend::Backend(", 1) + "\n"
+ if unicode:
+ header = ("\ufeff// caf\u00e9 \u96ea\n" + header).replace("\n", "\r\n")
+ implementation = ("\ufeff// caf\u00e9 \u96ea\n" + implementation).replace("\n", "\r\n")
+ return analysis(root, {"backend.hpp": header, "backend.cpp": implementation,
+ "Main.qml": "import QtQml\nQtObject { property int value: 1 }",
+ "app.qrc": 'Main.qml'})
+
+
+@pytest.mark.parametrize("inline,same_line,unicode", [(False, False, False), (False, True, False),
+ (True, False, False), (False, False, True), (True, False, True)])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml008_ac02_and_qml011_ac01_exact_overload_native_consumers(tmp_path, inline, same_line, unicode, directed):
+ """Canonical constructor IDs survive assembly/export/reload with exact body-owned native sites."""
+ result = fixture(tmp_path, inline=inline, same_line=same_line, unicode=unicode)
+ found = constructors(result)
+ assert len(found) == 2 and len({node["id"] for node in found}) == 2
+ owned = sites(result, "emission") + sites(result, "qml_access") + sites(result, "qml_load") + sites(result, "qml_root")
+ assert len(owned) == 8
+ assert all(qt_metadata(node)["status"] == "resolved" for node in owned)
+ assert {qt_metadata(node)["owner_id"] for node in owned} == {node["id"] for node in found}
+ for node in found:
+ scope = node["metadata"]["cpp_constructor"]
+ filename = node.get("definition_file") or node["source_file"]
+ span = scope.get("definition_span") or scope["span"]
+ for site in owned:
+ md = qt_metadata(site)
+ if md["owner_id"] != node["id"]:
+ continue
+ assert site["source_file"] == filename
+ assert span["start_byte"] <= md["span"]["start_byte"] < md["span"]["end_byte"] <= span["end_byte"]
+ if md["kind"] == "emission":
+ assert md["target_id"]
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ output = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(output))
+ reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ for site in sites(result, "emission"):
+ md = qt_metadata(site)
+ assert reloaded.has_edge(md["owner_id"], site["id"])
+ assert md["owner_id"] in {hit.node_id for hit in affected_nodes(reloaded, md["target_id"], depth=3)}
+ assert not any(data.get("relation") == "calls" for _, _, data in reloaded.edges(site["id"], data=True))
+ answer = _query_graph_text(reloaded, "Backend changed otherChanged", token_budget=8000)
+ assert "changed" in answer and "otherChanged" in answer and "backend.hpp" in answer
+
+
+def test_req_qml008_ac03_unknown_overload_signature_cannot_borrow_known_native_class(tmp_path):
+ """Unsupported alias constructor facts remain source-visible without guessed native endpoints."""
+ result = analysis(tmp_path, {
+ "backend.hpp": "class Backend: public QObject { Q_OBJECT public: Backend(int); Backend(Alias); signals: void changed(); };",
+ "backend.cpp": '#include "backend.hpp"\nBackend::Backend(Alias value) { emit changed(); }',
+ })
+ emission = qt_metadata(sites(result, "emission")[0])
+ assert emission["status"] != "resolved" and emission["target_id"] == ""
+ assert constructors(result)
+
+
+def test_req_qml008_ac03_duplicate_inline_signature_cannot_authorize_emission(tmp_path):
+ """Contradictory source bodies do not become one accepted inline constructor owner."""
+ result = analysis(tmp_path, {"backend.hpp": "class Backend: public QObject { Q_OBJECT public: "
+ "Backend(int value) { emit changed(); } Backend(const int other) { emit changed(); } "
+ "signals: void changed(); };"})
+ emissions = sites(result, "emission")
+ assert len(emissions) == 2
+ assert all(qt_metadata(node)["status"] != "resolved" and qt_metadata(node)["target_id"] == "" for node in emissions)
diff --git a/tests/test_qt_pipeline_failure_context.py b/tests/test_qt_pipeline_failure_context.py
new file mode 100644
index 0000000000..86830b330e
--- /dev/null
+++ b/tests/test_qt_pipeline_failure_context.py
@@ -0,0 +1,190 @@
+"""REQ-QML-012/020: join failures remain bounded after input identity breaks."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+import graphify.extract as extraction
+import graphify.qt_project_membership as membership
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.qt_qml_pipeline import resolve_qt_qml
+from graphify.watch import _rebuild_code
+from tests.qml_installed_smoke import native_module_smoke
+from tests.test_qt_final_incremental_parity import normalized, run
+from tests.test_qt_project_membership_aliases import aliased_parent # noqa: F401 - shared real host fixture
+
+
+NAMES = ("backend.h", "Main.qml", "loader.cpp", "CMakeLists.txt", "resources.qrc")
+PRIVATE_BODY = "private-filesystem-backend-body"
+
+
+def raw_project(root, *, metadata_path=None):
+ """Produce actual parser facts before the owning join creates its per-run index."""
+ paths = [metadata_path if name == "CMakeLists.txt" and metadata_path is not None
+ else root / name for name in (*NAMES, "keep.py")]
+ per_file = [extraction._safe_extract_with_xaml_root(
+ extraction._get_extractor(path), path, root) for path in paths]
+ assert all(not result.get("error") for result in per_file)
+ nodes = [node for result in per_file for node in result["nodes"]]
+ edges = [edge for result in per_file for edge in result["edges"]]
+ return paths, per_file, nodes, edges
+
+
+def project(root):
+ native_module_smoke(root)
+ (root / "keep.py").write_bytes(b"def retained(): return 7\n")
+
+
+def arm_failure(monkeypatch, build, error_type):
+ """Fail only the accepted input after real parsing, collection, and indexing."""
+ real_membership, real_resolve = membership.resolve_project_memberships, Path.resolve
+ reached = []
+
+ def fail_input(path, *args, **kwargs):
+ if path == build:
+ raise error_type(PRIVATE_BODY)
+ return real_resolve(path, *args, **kwargs)
+
+ def arm(results, *args, **kwargs):
+ assert build in results and kwargs["project_index"].nodes
+ assert any(qml_metadata(node).get("kind") == "qt_source"
+ for node in results[build]["nodes"])
+ reached.append("accepted_join")
+ if error_type is None:
+ raise ValueError(PRIVATE_BODY)
+ # The patch survives the membership exception, so the real diagnostic
+ # path must withstand the same persistent OSError or alias-loop failure.
+ monkeypatch.setattr(Path, "resolve", fail_input)
+ return real_membership(results, *args, **kwargs)
+
+ monkeypatch.setattr(membership, "resolve_project_memberships", arm)
+ return reached
+
+
+def assert_failure_context(paths, per_file, failed, expected_build):
+ """Every affected source has bounded context; unrelated Python stays unaffected."""
+ assert set(failed) == {str(path) for path in paths if path.name != "keep.py"}
+ for path, result in zip(paths, per_file):
+ if path.name == "keep.py":
+ assert not result.get("qml_failures") and not result.get("diagnostics")
+ continue
+ source = expected_build if path.name == "CMakeLists.txt" else path.name
+ assert result["qml_failures"] == [{"code": "QML_RESOLUTION_FAILED", "source_file": source}]
+ assert result["diagnostics"] == [{
+ "code": "QML_RESOLUTION_FAILED", "severity": "error", "owner": "qt_qml_resolution",
+ "source_file": source, "message": "Qt/QML project join failed",
+ "recovery": "Correct the join failure and retry; prior graph is preserved.",
+ }]
+ assert PRIVATE_BODY not in json.dumps(per_file)
+
+
+@pytest.mark.parametrize("error_type", [None, OSError, RuntimeError])
+def test_req_qml012_ac01_all_sources_keep_safe_context_after_join_failure(
+ tmp_path, monkeypatch, error_type):
+ """Ordinary and persistent failures return complete diagnostics, never backend exceptions."""
+ root = tmp_path / "native-profile"
+ project(root)
+ paths, per_file, nodes, edges = raw_project(root)
+ reached = arm_failure(monkeypatch, root / "CMakeLists.txt", error_type)
+ failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root)
+ assert reached == ["accepted_join"]
+ assert_failure_context(paths, per_file, failed, "CMakeLists.txt")
+
+
+@pytest.mark.parametrize("error_type", [None, OSError, RuntimeError])
+def test_req_qml012_ac01_real_alias_uses_canonical_or_explicit_unavailable_context(
+ aliased_parent, monkeypatch, error_type):
+ """Real parent aliases retain canonical context, or an empty label when identity is inaccessible."""
+ parent, alias = aliased_parent
+ root = parent / "native-profile"
+ project(root)
+ build = alias / "native-profile/CMakeLists.txt"
+ assert build.resolve() == root / "CMakeLists.txt"
+ paths, per_file, nodes, edges = raw_project(root, metadata_path=build)
+ reached = arm_failure(monkeypatch, build, error_type)
+ failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root)
+ assert reached == ["accepted_join"]
+ assert_failure_context(paths, per_file, failed, "CMakeLists.txt" if error_type is None else "")
+ assert all(not qml_metadata(node).get("kind") == "membership_resolution" for node in nodes)
+
+
+def test_req_qml020_ac02_foreign_transport_cannot_gain_authority_from_diagnostic_context(tmp_path):
+ """A rejected outside-root owner has no private path label and publishes no fallback target."""
+ root = tmp_path / "native-profile"
+ project(root)
+ paths, per_file, nodes, edges = raw_project(root)
+ paths[NAMES.index("CMakeLists.txt")] = tmp_path / "private-external-input/CMakeLists.txt"
+ failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root)
+ assert_failure_context(paths, per_file, failed, "")
+ assert "private-external-input" not in json.dumps(per_file)
+ assert not any(qml_metadata(node).get("kind") == "membership_resolution" for node in nodes)
+ assert not any(edge.get("context") in {"qt_project_source", "qt_resource_membership"} for edge in edges)
+
+
+@pytest.mark.parametrize("delimiter", ["\n", "\r", "\t", "\x00", "\x1b", "\x7f", "\x85", "\u2028", "\u2029"])
+def test_req_qml012_ac01_injected_context_delimiters_are_explicitly_unavailable(tmp_path, delimiter):
+ """Untrusted transported labels cannot inject control characters into join diagnostics."""
+ root = tmp_path / "native-profile"
+ project(root)
+ paths, per_file, nodes, edges = raw_project(root)
+ paths[NAMES.index("CMakeLists.txt")] = root / ("injected" + delimiter + "label/CMakeLists.txt")
+ failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root)
+ assert_failure_context(paths, per_file, failed, "")
+ assert delimiter not in "".join(diagnostic["source_file"] for result in per_file
+ for diagnostic in result.get("diagnostics", []))
+
+
+@pytest.mark.parametrize("length", [160, 161])
+def test_req_qml012_ac01_context_ceiling_preserves_or_rejects_complete_relative_label(tmp_path, length):
+ """The diagnostic-only 160-character ceiling never truncates a source into another identity."""
+ root = tmp_path / "native-profile"
+ project(root)
+ paths, per_file, nodes, edges = raw_project(root)
+ relative = "a" * (length - len("/CMakeLists.txt")) + "/CMakeLists.txt"
+ assert len(relative) == length
+ paths[NAMES.index("CMakeLists.txt")] = root / relative
+ failed = resolve_qt_qml(paths, per_file, nodes, edges, root=root)
+ assert_failure_context(paths, per_file, failed, relative if length == 160 else "")
+ assert not any(qml_metadata(node).get("kind") == "membership_resolution" for node in nodes)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("error_type", [OSError, RuntimeError])
+def test_req_qml012_ac02_persistent_failure_retains_products_and_repairs_without_leaking(
+ tmp_path, monkeypatch, capsys, operation, error_type):
+ """Actual update writers reject safely, keep four durable products, repair, then repeat."""
+ root = tmp_path / "native-profile"
+ project(root)
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(root, monkeypatch, operation)
+ output = root / "graphify-out"
+ names = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+ before = {name: (output / name).read_bytes() for name in names}
+ build = root / "CMakeLists.txt"
+ original = build.read_bytes()
+ build.write_bytes(original + b"\n# trigger accepted-source refresh\n")
+ capsys.readouterr()
+ with monkeypatch.context() as failure:
+ reached = arm_failure(failure, build, error_type)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(root, failure, operation, [build])
+ assert rejected.value.code == 1
+ else:
+ assert not _rebuild_code(root, changed_paths=[build], no_cluster=True)
+ assert reached == ["accepted_join"]
+ captured = capsys.readouterr()
+ assert PRIVATE_BODY not in captured.out + captured.err
+ # The join's per-source code is asserted above. The actual writer retains
+ # its separate documented publication-rejection diagnostic at this boundary.
+ assert "QML_GRAPH_PRESERVED" in captured.out + captured.err
+ assert before == {name: (output / name).read_bytes() for name in names}
+ build.write_bytes(original)
+ repaired = run(root, monkeypatch, operation, [build])
+ assert normalized(repaired) == normalized(initial)
+ repaired_products = {name: (output / name).read_bytes() for name in names}
+ assert normalized(run(root, monkeypatch, operation, [])) == normalized(initial)
+ assert repaired_products == {name: (output / name).read_bytes() for name in names}
diff --git a/tests/test_qt_product_publication.py b/tests/test_qt_product_publication.py
new file mode 100644
index 0000000000..c28b518764
--- /dev/null
+++ b/tests/test_qt_product_publication.py
@@ -0,0 +1,253 @@
+"""INC-QML-25: real manual/watch updates publish one accepted product cohort."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import stat
+import sys
+
+import pytest
+
+import graphify.detect as detection
+import graphify.qt_analysis_state as qt_state
+import graphify.publication as publication
+import graphify.watch as watch
+from tests.test_qt_final_incremental_parity import clean, normalized, project, run
+
+
+PRODUCTS = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json")
+
+
+def snapshot(output):
+ """Compare durable accepted bytes, including absent first-build products."""
+ return {name: (output / name).read_bytes() if (output / name).exists() else None
+ for name in PRODUCTS}
+
+
+def no_scratch(output):
+ assert not list(output.glob(".gfy-publish-*"))
+ assert not (output / ".graph.tmp.json").exists()
+
+
+def reject(root, monkeypatch, operation, source):
+ if operation == "manual":
+ with pytest.raises(SystemExit) as result:
+ run(root, monkeypatch, operation, [source])
+ assert result.value.code == 1
+ else:
+ assert not watch._rebuild_code(root, changed_paths=[source], no_cluster=True)
+
+
+def accepted_project(root, monkeypatch, operation):
+ source = project(root)
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(root, monkeypatch, operation)
+ source.write_text(source.read_text(encoding="utf-8").replace("adjustment", "offset"),
+ encoding="utf-8", newline="")
+ return source, initial, root / "graphify-out"
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute")
+@pytest.mark.parametrize("name", PRODUCTS)
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_inc25_readonly_each_product_preserves_cohort_and_retries(tmp_path, monkeypatch, capsys, name, operation):
+ """Each changed output rejects before any accepted sibling advances, then recovers."""
+ source, initial, output = accepted_project(tmp_path, monkeypatch, operation)
+ if name == ".graphify_root":
+ marker = output / name
+ marker.write_bytes(b"\xef\xbb\xbf" + marker.read_bytes() + b"\r\n")
+ if name == ".qt_analysis.json":
+ (tmp_path / "extra").mkdir()
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", '[".","extra"]')
+ before = snapshot(output)
+ target = output / name
+ target.chmod(stat.S_IREAD)
+ try:
+ reject(tmp_path, monkeypatch, operation, source)
+ assert snapshot(output) == before
+ assert not target.stat().st_mode & stat.S_IWRITE
+ assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out
+ no_scratch(output)
+ finally:
+ target.chmod(stat.S_IWRITE)
+ repaired = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(repaired) != normalized(initial)
+ # Clean extraction uses the same configured import roots, not a copied resolver.
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean"))
+ current = snapshot(output)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired)
+ assert snapshot(output) == current
+ no_scratch(output)
+
+
+@pytest.mark.parametrize("fault", ["manifest", "stamp", "replace"])
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_inc25_preparation_and_late_replace_faults_preserve_then_retry(tmp_path, monkeypatch, capsys, fault, operation):
+ """Real serializers run before an injected failure; a late replacement rolls back disk."""
+ source, _, output = accepted_project(tmp_path, monkeypatch, operation)
+ before = snapshot(output)
+ with monkeypatch.context() as failure:
+ if fault in {"manifest", "stamp"}:
+ module, name = ((detection, "save_manifest") if fault == "manifest"
+ else (qt_state, "commit_qt_analysis"))
+ original = getattr(module, name)
+
+ def write_then_fail(*args, **kwargs):
+ original(*args, **kwargs)
+ raise OSError("public test preparation failure")
+
+ failure.setattr(module, name, write_then_fail)
+ else:
+ original = watch.os_replace_with_fallback
+
+ def fail_late(src, dst):
+ if dst.name == "manifest.json":
+ raise OSError("public test publication failure")
+ return original(src, dst)
+
+ failure.setattr(watch, "os_replace_with_fallback", fail_late)
+ reject(tmp_path, monkeypatch, operation, source)
+ assert snapshot(output) == before
+ assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out
+ no_scratch(output)
+ repaired = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".clean"))
+ current = snapshot(output)
+ run(tmp_path, monkeypatch, operation, [])
+ assert snapshot(output) == current
+
+
+def test_inc25_clustered_report_failure_preserves_graph_and_sidecars(tmp_path, monkeypatch):
+ """Clustering/report preparation cannot publish graph before a failed report write."""
+ source = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert watch._rebuild_code(tmp_path)
+ output = tmp_path / "graphify-out"
+ names = (*PRODUCTS, "GRAPH_REPORT.md", ".graphify_labels.json", ".graphify_labels.json.sig")
+ before = {name: (output / name).read_bytes() for name in names}
+ source.write_text(source.read_text(encoding="utf-8").replace("adjustment", "offset"), encoding="utf-8")
+ original = watch.write_text_atomic
+
+ def report_fault(path, text, **kwargs):
+ if path.name == "GRAPH_REPORT.md":
+ raise OSError("public test report failure")
+ return original(path, text, **kwargs)
+
+ with monkeypatch.context() as failure:
+ failure.setattr(watch, "write_text_atomic", report_fault)
+ assert not watch._rebuild_code(tmp_path, changed_paths=[source])
+ assert {name: (output / name).read_bytes() for name in names} == before
+ no_scratch(output)
+ assert watch._rebuild_code(tmp_path, changed_paths=[source])
+
+
+def test_inc25_successful_ast_cache_does_not_authorize_failed_products(tmp_path, monkeypatch):
+ """Content-keyed valid syntax may be reusable despite rejected graph publication."""
+ source = tmp_path / "sample.py"
+ source.write_text("def initial():\n return 1\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ assert watch._rebuild_code(tmp_path, no_cluster=True)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ cache_root = output / "cache"
+ caches = {path.relative_to(cache_root): path.read_bytes() for path in cache_root.rglob("*") if path.is_file()}
+ source.write_text("def initial():\n return 1\n\ndef added():\n return initial()\n", encoding="utf-8")
+ with monkeypatch.context() as failure:
+ failure.setattr(detection, "save_manifest", lambda *args, **kwargs: (_ for _ in ()).throw(OSError("failed manifest")))
+ assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert snapshot(output) == before
+ after = {path.relative_to(cache_root): path.read_bytes() for path in cache_root.rglob("*") if path.is_file()}
+ assert all(after.get(key) == original for key, original in caches.items())
+ assert set(after) > set(caches)
+ no_scratch(output)
+ assert watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+
+
+@pytest.mark.parametrize("no_cluster", [True, False])
+def test_inc25_unchanged_graph_manifest_fault_is_still_rejected(tmp_path, monkeypatch, capsys, no_cluster):
+ """An unchanged topology still needs committed input hashes before success."""
+ source = tmp_path / "sample.py"
+ source.write_text("def unchanged():\n return 1\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ assert watch._rebuild_code(tmp_path, no_cluster=no_cluster)
+ # Legacy raw output receives reconciliation provenance on its first reload;
+ # establish that accepted steady-state before exercising the fast path.
+ assert watch._rebuild_code(tmp_path, no_cluster=no_cluster)
+ output = tmp_path / "graphify-out"
+ before = snapshot(output)
+ # A value-only edit changes accepted input bytes without changing declarations.
+ source.write_text(source.read_text(encoding="utf-8").replace("return 1", "return 2"), encoding="utf-8")
+ original = detection.save_manifest
+
+ def fault(*args, **kwargs):
+ original(*args, **kwargs)
+ raise OSError("manifest preparation failed")
+
+ with monkeypatch.context() as failure:
+ failure.setattr(detection, "save_manifest", fault)
+ assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=no_cluster)
+ assert snapshot(output) == before
+ assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out
+ no_scratch(output)
+ assert watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=no_cluster)
+ assert (output / "graph.json").read_bytes() == before["graph.json"]
+ assert (output / "manifest.json").read_bytes() != before["manifest.json"]
+
+
+def test_inc25_first_build_failure_publishes_no_acceptance_products(tmp_path, monkeypatch):
+ """Failed preparation cannot bless a new graph with absent acceptance sidecars."""
+ source = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ output = tmp_path / "graphify-out"
+ with monkeypatch.context() as failure:
+ failure.setattr(detection, "save_manifest", lambda *args, **kwargs: (_ for _ in ()).throw(OSError("manifest failure")))
+ assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert snapshot(output) == {name: None for name in PRODUCTS}
+ no_scratch(output)
+ assert watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert all((output / name).exists() for name in PRODUCTS)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("phase", ["output_dir", "scratch_dir"])
+def test_inc25_actual_caller_setup_failure_retains_and_redacts(tmp_path, monkeypatch, capsys, operation, phase):
+ """The real driver logs only safe setup diagnostics, preserving all accepted bytes."""
+ source, initial, output = accepted_project(tmp_path, monkeypatch, operation)
+ before = snapshot(output)
+ capsys.readouterr()
+ private_marker = "synthetic-sensitive-location"
+ error = PermissionError(13, private_marker + "\nbody", str(tmp_path / private_marker))
+ mkdir = Path.mkdir
+
+ def failed_mkdir(path, *args, **kwargs):
+ # Isolate only the external transaction-directory syscall; queue/lock
+ # setup and the real serializer/driver continue unchanged.
+ if path == output and sys._getframe(1).f_code is publication.ProductPublication.__init__.__code__:
+ raise error
+ return mkdir(path, *args, **kwargs)
+
+ def failed_scratch(*args, **kwargs):
+ raise error
+
+ with monkeypatch.context() as failure:
+ if phase == "output_dir":
+ failure.setattr(Path, "mkdir", failed_mkdir)
+ else:
+ failure.setattr(publication.tempfile, "mkdtemp", failed_scratch)
+ reject(tmp_path, monkeypatch, operation, source)
+ captured = capsys.readouterr()
+ assert "GRAPH_PUBLICATION_FAILED" in captured.out
+ assert private_marker not in captured.out + captured.err
+ # The existing CLI progress message names its target. The new rejection
+ # diagnostic must expose neither that path nor the underlying error body.
+ diagnostic = next(line for line in captured.out.splitlines() if "Rebuild failed:" in line)
+ assert str(tmp_path) not in diagnostic
+ assert snapshot(output) == before
+ no_scratch(output)
+ repaired = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(repaired) != normalized(initial)
+ current = snapshot(output)
+ run(tmp_path, monkeypatch, operation, [])
+ assert snapshot(output) == current
+ no_scratch(output)
diff --git a/tests/test_qt_project_admission.py b/tests/test_qt_project_admission.py
new file mode 100644
index 0000000000..6a40110865
--- /dev/null
+++ b/tests/test_qt_project_admission.py
@@ -0,0 +1,139 @@
+"""QML-008/009/017 public build providers join through the actual facade."""
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_project_index import QtProjectIndex
+from tests.qt_analysis_helpers import analysis, sites
+
+HEADER = '''class Backend : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+ Q_PROPERTY(int count READ count)
+public:
+ int count() { return 1; }
+ Q_INVOKABLE int next(int value) { return value; }
+};
+'''
+QML = '''import Public.Tools 1.0
+Backend { property int copied: count; function run() { next(1) } }
+'''
+QRC = 'Main.qml'
+BUILDS = {
+ "CMakeLists.txt": 'qt_add_qml_module(tools URI Public.Tools VERSION 1.2 RESOURCE_PREFIX /ui '
+ 'QML_FILES Main.qml SOURCES backend.h loader.cpp)\n',
+ "tools.pro": 'TARGET = tools\nQML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.2\n'
+ 'HEADERS += backend.h\nSOURCES += loader.cpp\nQML_FILES += Main.qml\nRESOURCES += resources.qrc\n',
+}
+
+
+def facts(result, kind, reference=None):
+ return [node for node in result["nodes"] if (md := qml_metadata(node)).get("kind") == kind
+ and (reference is None or md.get("reference") == reference)]
+
+
+@pytest.mark.parametrize("build_file", list(BUILDS))
+def test_qml008_ac01_public_element_build_membership_and_canonical_member_endpoints(tmp_path, build_file):
+ """CMake and qmake provide the same URI/version and canonical C++ members."""
+ result = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML,
+ build_file: BUILDS[build_file], "resources.qrc": QRC})
+ project = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path)
+ module = project.module_context("backend.h")
+ assert module.status == "resolved"
+ assert project.module_metadata(module.target_id)["uri"] == "Public.Tools"
+ for kind, name in (("read", "count"), ("call", "next")):
+ site = facts(result, kind, name)[0]
+ md = qml_metadata(site)
+ assert md["status"] == "resolved", md
+ target = next(node for node in result["nodes"] if node["id"] == md["resolved_target_id"])
+ assert target["source_file"] == "backend.h"
+ outgoing = [edge for edge in result["edges"] if edge["source"] == site["id"] and edge["target"] == target["id"]]
+ assert len(outgoing) == 1 and qt_metadata(outgoing[0])["native_endpoint"]
+ if kind == "call":
+ assert target.get("_callable") and not qt_metadata(target)
+
+
+@pytest.mark.parametrize("build_file", list(BUILDS))
+def test_qml009_ac03_public_metadata_qrc_load_build_export_reload(tmp_path, build_file):
+ """Literal resources and module loads preserve endpoints through persisted graphs."""
+ source = '''void use() { QQmlApplicationEngine engine;
+ engine.load(QUrl("qrc:/ui/Main.qml")); }
+'''
+ result = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML, "loader.cpp": source,
+ build_file: BUILDS[build_file], "resources.qrc": QRC})
+ loader = sites(result, "qml_load")[0]
+ assert qt_metadata(loader)["status"] == "resolved"
+ component = facts(result, "component")[0]
+ assert qt_metadata(loader)["target_id"] == component["id"]
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "roundtrip.json"
+ assert to_json(graph, {}, str(output), force=True)
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert restored.has_edge(loader["id"], component["id"])
+ edge = restored.edges[loader["id"], component["id"]]
+ assert edge["context"] == "qt_cpp_qml_load" and edge["confidence"] == "INFERRED"
+ assert edge["source_file"] == "loader.cpp" and qt_metadata(edge)["target_id"] == component["id"]
+
+
+def test_qml008_ac03_macro_without_membership_or_wrong_module_does_not_guess(tmp_path):
+ """A class name or neighboring module is insufficient provider provenance."""
+ no_build = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML})
+ assert qml_metadata(facts(no_build, "read", "count")[0])["status"] != "resolved"
+ wrong = analysis(tmp_path, {"backend.h": HEADER, "Main.qml": QML,
+ "CMakeLists.txt": 'qt_add_qml_module(tools URI Other.Tools VERSION 1.2 SOURCES backend.h)\n'})
+ assert qml_metadata(facts(wrong, "read", "count")[0])["status"] != "resolved"
+
+
+def test_qml009_ac03_build_sources_do_not_expand_accepted_cpp_corpus(tmp_path):
+ """A disk-only header listed by CMake cannot create a native endpoint."""
+ (tmp_path / "backend.h").write_text(HEADER, encoding="utf-8")
+ result = analysis(tmp_path, {"Main.qml": QML, "CMakeLists.txt": BUILDS["CMakeLists.txt"]})
+ assert not sites(result, "class")
+ assert qml_metadata(facts(result, "read", "count")[0])["status"] != "resolved"
+
+
+@pytest.mark.parametrize("build_file", list(BUILDS))
+def test_qml017_ac01_literal_module_load_reaches_declared_component_and_property(tmp_path, build_file):
+ """Both build profiles supply the exact module/type loader provenance."""
+ source = '''void use(){ QQmlApplicationEngine engine;
+ engine.loadFromModule("Public.Tools", "Main"); auto root = engine.rootObjects().first();
+ root->setProperty("count", 2); }'''
+ result = analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int count: 1 }',
+ "loader.cpp": source, build_file: BUILDS[build_file], "resources.qrc": QRC})
+ for kind in ("qml_load", "qml_root", "qml_access"):
+ assert qt_metadata(sites(result, kind)[0])["status"] == "resolved"
+ loader = qt_metadata(sites(result, "qml_load")[0])
+ assert loader["target_id"] == facts(result, "component")[0]["id"]
+
+
+def test_qml009_ac03_generated_resource_path_requires_explicit_prefix(tmp_path):
+ """A literal CMake prefix creates aliases; unevaluated policy defaults do not."""
+ source = 'void use(){ QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Public/Tools/Main.qml")); }'
+ files = {"Main.qml": 'import QtQml\nQtObject {}', "loader.cpp": source,
+ "CMakeLists.txt": BUILDS["CMakeLists.txt"]}
+ result = analysis(tmp_path, files)
+ assert qt_metadata(sites(result, "qml_load")[0])["status"] == "resolved"
+ files["CMakeLists.txt"] = files["CMakeLists.txt"].replace("RESOURCE_PREFIX /ui ", "")
+ result2 = analysis(tmp_path, files)
+ md = qt_metadata(sites(result2, "qml_load")[0])
+ assert (md["status"], md["reason"]) == ("unavailable", "resource_alias_unavailable")
+
+
+def test_qml017_ac01_resource_duplicate_and_unaccepted_targets_remain_unresolved(tmp_path):
+ """A duplicate alias and a disk-only target cannot authorize a C++ load edge."""
+ source = 'void use(){ QQmlApplicationEngine engine; engine.load(QUrl("qrc:/ui/Main.qml")); }'
+ duplicate = QRC.replace('', 'Main.qml')
+ result = analysis(tmp_path, {"loader.cpp": source, "Main.qml": 'import QtQml\nQtObject {}', "resources.qrc": duplicate})
+ md = qt_metadata(sites(result, "qml_load")[0])
+ assert (md["status"], md["reason"]) == ("ambiguous", "duplicate_resource_alias")
+ hidden = tmp_path / "Hidden.qml"
+ hidden.write_text('import QtQml\nQtObject {}', encoding="utf-8")
+ result2 = analysis(tmp_path, {"loader.cpp": source,
+ "resources.qrc": QRC.replace('>Main.qml<', '>Hidden.qml<')})
+ md2 = qt_metadata(sites(result2, "qml_load")[0])
+ assert (md2["status"], md2["reason"]) == ("unavailable", "resource_target_outside_corpus")
diff --git a/tests/test_qt_project_membership.py b/tests/test_qt_project_membership.py
new file mode 100644
index 0000000000..6314aca2a6
--- /dev/null
+++ b/tests/test_qt_project_membership.py
@@ -0,0 +1,283 @@
+"""REQ-QML-020 publishes literal metadata membership without runtime guesses."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract
+from graphify.extractors.qml_facts import encode_metadata, qml_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_project_index import QtProjectIndex
+
+
+def fixture(root, system="cmake", resource="resources.qrc"):
+ """An unused frontend still has explicit build and resource declarations."""
+ sources = {
+ "qml/ResizeHandle.qml": "import QtQuick\nItem { id: handle; property int position: 1 }\n",
+ "backend.cpp": "class Backend {};\n",
+ resource: ''
+ 'qml/ResizeHandle.qml',
+ }
+ if system == "cmake":
+ sources["CMakeLists.txt"] = "qt_add_qml_module(app URI Public.Layout VERSION 1.0 " \
+ "QML_FILES qml/ResizeHandle.qml SOURCES backend.cpp)\n"
+ else:
+ sources["project.pro"] = "QML_IMPORT_NAME = Public.Layout\nQML_IMPORT_VERSION = 1.0\n" \
+ "QML_FILES += qml/ResizeHandle.qml\nSOURCES += backend.cpp\nRESOURCES += " + resource + "\n"
+ paths = []
+ for name, text in sources.items():
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8", newline="")
+ paths.append(path)
+ result = extract(paths, root=root, cache_root=root, parallel=False)
+ assert not result.get("qml_failures")
+ return result
+
+
+def facts(result, kind):
+ return [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind]
+
+
+def apply(root, nodes, edges, declarations, **kwargs):
+ """Use actual accepted producer records and the production per-run index."""
+ from graphify.qt_project_membership import resolve_project_memberships
+
+ results = {}
+ for node in declarations:
+ results.setdefault(root / node["source_file"], {"nodes": [], "edges": []})["nodes"].append(node)
+ index = QtProjectIndex(nodes, edges, root=root)
+ resolve_project_memberships(results, nodes, edges, root=root, project_index=index, **kwargs)
+ return [node for result in results.values() for node in result["nodes"]
+ if qml_metadata(node).get("kind") == "membership_resolution"]
+
+
+@pytest.mark.parametrize("system", ["cmake", "qmake"])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml020_ac01_unused_component_has_persisted_build_and_resource_membership(tmp_path, system, directed):
+ """Real facade/build/reload connects declared source membership, with no QML instantiation."""
+ result = fixture(tmp_path, system)
+ declarations = facts(result, "qt_source") + facts(result, "resource_alias")
+ sites = facts(result, "membership_resolution")
+ assert len(sites) == len(declarations)
+ assert all(qml_metadata(site)["status"] == "resolved" for site in sites)
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path))
+ reloaded = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ assert reloaded.is_directed() is directed
+ component = facts(result, "component")[0]["id"]
+ for site in sites:
+ md = qml_metadata(site)
+ assert reloaded.has_edge(md["declaration_id"], site["id"])
+ edge = reloaded.edges[site["id"], md["target_id"]]
+ assert edge["confidence"] == "EXTRACTED" and edge["relation"] == "references"
+ # Directed transport owns direction structurally; the undirected form
+ # needs its explicit logical endpoint hints to retain that information.
+ if directed:
+ assert not reloaded.has_edge(md["target_id"], site["id"])
+ else:
+ assert (edge["_src"], edge["_tgt"]) == (site["id"], md["target_id"])
+ assert qml_metadata(edge)["span"] == md["span"]
+ assert any(qml_metadata(site)["target_id"] == component for site in sites)
+ index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path)
+ assert index.resolve_url("qrc:/ui/handle.qml").target_id == component
+ assert index.resolve_component("Public.Layout", "ResizeHandle", 1, 0).target_id == component
+
+
+@pytest.mark.parametrize("corruption", ["missing", "duplicate", "outside_root", "generated",
+ "conditional", "foreign_module", "duplicate_module", "non_file",
+ "non_ast_module", "non_ast_target", "non_ast_declaration"])
+def test_req_qml020_ac02_source_projection_rejects_unproved_membership(tmp_path, corruption):
+ """Wrong paths, source roles and module authority cannot create a guessed endpoint."""
+ result = fixture(tmp_path)
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source"
+ and qml_metadata(node).get("source_kind") == "qml")
+ target = next(node for node in nodes if qml_metadata(node).get("kind") == "component")
+ md = qml_metadata(declaration)
+ if corruption == "missing":
+ nodes[:] = [node for node in nodes if node["source_file"] != target["source_file"]]
+ elif corruption == "duplicate":
+ nodes.append({**copy.deepcopy(target), "id": target["id"] + "_competing"})
+ elif corruption == "non_file":
+ target["metadata"]["qml"] = encode_metadata({"kind": "function"})
+ for node in nodes:
+ if node["source_file"] == target["source_file"]:
+ node["type"] = "function"
+ node["_callable"] = True
+ elif corruption == "foreign_module":
+ for node in nodes:
+ if qml_metadata(node).get("kind") == "qt_module":
+ node["source_file"] = "foreign/CMakeLists.txt"
+ elif corruption == "duplicate_module":
+ module = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_module")
+ nodes.append({**copy.deepcopy(module), "id": module["id"] + "_competing"})
+ elif corruption in {"non_ast_module", "non_ast_target", "non_ast_declaration"}:
+ chosen = (next(node for node in nodes if qml_metadata(node).get("kind") == "qt_module")
+ if corruption == "non_ast_module" else target if corruption == "non_ast_target" else declaration)
+ chosen["_origin"] = "semantic"
+ else:
+ md[{"outside_root": "value", "generated": "generated", "conditional": "conditional"}[corruption]] = (
+ "../../foreign.qml" if corruption == "outside_root" else True)
+ declaration["metadata"]["qml"] = encode_metadata({key: value for key, value in md.items() if key != "raw_values"})
+ borrowed = list(nodes)
+ before = copy.deepcopy((nodes, edges, declaration))
+ sites = apply(tmp_path, nodes, edges, [declaration])
+ assert len(sites) == 1 and qml_metadata(sites[0])["status"] != "resolved"
+ assert not qml_metadata(sites[0])["target_id"]
+ assert not [edge for edge in edges if edge.get("source") == sites[0]["id"]]
+ assert declaration == before[2]
+ assert borrowed == before[0]
+
+
+@pytest.mark.parametrize("corruption", ["duplicate", "locale", "generated", "missing", "outside_root"])
+def test_req_qml020_ac02_resources_reuse_existing_alias_guard_decisions(tmp_path, corruption):
+ """Membership cannot reinterpret ambiguous/localized resource lookup as runtime authority."""
+ result = fixture(tmp_path)
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "resource_alias")
+ md = qml_metadata(declaration)
+ if corruption == "duplicate":
+ nodes.append({**copy.deepcopy(declaration), "id": declaration["id"] + "_competing"})
+ elif corruption == "missing":
+ nodes[:] = [node for node in nodes if node["source_file"] != md["target_path"]]
+ else:
+ md[{"locale": "locale", "generated": "generated", "outside_root": "target_path"}[corruption]] = (
+ "fr" if corruption == "locale" else "../foreign.qml" if corruption == "outside_root" else True)
+ declaration["metadata"]["qml"] = encode_metadata({key: value for key, value in md.items() if key != "raw_values"})
+ sites = apply(tmp_path, nodes, edges, [declaration])
+ assert qml_metadata(sites[0])["status"] != "resolved"
+ assert not [edge for edge in edges if edge.get("source") == sites[0]["id"]]
+
+
+def test_req_qml020_ac03_fresh_derived_sites_replace_borrowed_state_without_mutation(tmp_path):
+ """Deleting a provider keeps the site's ID while removing its stale target edge."""
+ result = fixture(tmp_path)
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "resource_alias")
+ old_site = apply(tmp_path, nodes, edges, [declaration])[0]
+ retained = copy.deepcopy(old_site)
+ target_file = qml_metadata(declaration)["target_path"]
+ nodes[:] = [node for node in nodes if node["source_file"] != target_file]
+ new_site = apply(tmp_path, nodes, edges, [declaration])[0]
+ assert old_site == retained and old_site is not new_site and new_site["id"] == old_site["id"]
+ assert qml_metadata(new_site)["status"] != "resolved"
+ assert not [edge for edge in edges if edge.get("source") == new_site["id"]]
+ snapshot = copy.deepcopy((nodes, edges))
+ apply(tmp_path, nodes, edges, [declaration])
+ assert (nodes, edges) == snapshot
+
+
+def test_req_qml020_ac01_metadata_spans_survive_bom_crlf_unicode_and_sanitation(tmp_path):
+ """Projection carries original declaration byte offsets and decoded alias punctuation."""
+ component = tmp_path / "qml/ResizeHandle.qml"
+ component.parent.mkdir()
+ component.write_text("Item {}", encoding="utf-8")
+ resource = tmp_path / "resources.qrc"
+ raw = '\ufeff\r\n\r\nqml/ResizeHandle.qml\r\n\r\n'.encode("utf-8")
+ resource.write_bytes(raw)
+ result = extract([component, resource], root=tmp_path, cache_root=tmp_path, parallel=False)
+ site = facts(result, "membership_resolution")[0]
+ span = qml_metadata(site)["span"]
+ assert raw[span["start_byte"]:span["end_byte"]] == b'qml/ResizeHandle.qml'
+ graph = build_from_json(result, root=tmp_path)
+ assert qml_metadata(graph.nodes[site["id"]])["logical_url"] == "qrc:/ui/handle&.qml"
+
+
+def test_req_qml020_ac02_corrupt_literal_transport_is_rejected(tmp_path):
+ """Corruption fails at the actual seam instead of resolving escaped display data."""
+ result = fixture(tmp_path)
+ declaration = facts(result, "resource_alias")[0]
+ declaration["metadata"]["qml"]["raw_values"]["target_path"] = "invalid!"
+ with pytest.raises(ValueError, match="QML_METADATA"):
+ apply(tmp_path, result["nodes"], result["edges"], [declaration])
+
+
+def test_req_qml020_ac01_scoped_paths_repeated_declarations_and_query_keep_exact_identity(tmp_path):
+ """Equal basenames and repeated declarations keep distinct evidence and source scope."""
+ from graphify.serve import _query_graph_text
+
+ paths = [tmp_path / "left/Handle.qml", tmp_path / "right/Handle.qml", tmp_path / "CMakeLists.txt"]
+ for path in paths[:2]:
+ path.parent.mkdir()
+ path.write_text("Item {}", encoding="utf-8")
+ paths[2].write_text("qt_add_qml_module(app URI Public.Layout VERSION 1.0 "
+ "QML_FILES left/Handle.qml right/Handle.qml left/Handle.qml)", encoding="utf-8")
+ source_bytes = {path: path.read_bytes() for path in paths}
+ result = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ declarations, sites = facts(result, "qt_source"), facts(result, "membership_resolution")
+ assert len(declarations) == len(sites) == 3 and len({site["id"] for site in sites}) == 3
+ components = {node["source_file"]: node["id"] for node in facts(result, "component")}
+ assert [qml_metadata(site)["target_id"] for site in sites].count(components["left/Handle.qml"]) == 2
+ assert [qml_metadata(site)["target_id"] for site in sites].count(components["right/Handle.qml"]) == 1
+ before = copy.deepcopy(declarations)
+ index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path)
+ lookup = index.resolve_component("Public.Layout", "Handle", 1, 0)
+ assert lookup.status == "ambiguous"
+ apply(tmp_path, result["nodes"], result["edges"], declarations)
+ assert declarations == before
+ assert QtProjectIndex(result["nodes"], result["edges"], root=tmp_path).resolve_component(
+ "Public.Layout", "Handle", 1, 0) == lookup
+ graph = build_from_json(result, root=tmp_path)
+ answer = _query_graph_text(graph, "left/Handle.qml", token_budget=8000)
+ assert "left/Handle.qml" in answer and "CMakeLists.txt" in answer and "references" in answer
+ assert source_bytes == {path: path.read_bytes() for path in paths}
+
+
+def test_req_qml020_ac02_projection_reads_no_targets_and_executes_no_corpus(tmp_path, monkeypatch):
+ """Source parsing precedes this guard; the index/join uses only accepted dictionaries."""
+ import builtins
+ import subprocess
+ from pathlib import Path
+ from graphify.qt_project_membership import resolve_project_memberships
+
+ result = fixture(tmp_path)
+ declarations = facts(result, "qt_source") + facts(result, "resource_alias")
+ active = {}
+ for node in declarations:
+ active.setdefault(tmp_path / node["source_file"], {"nodes": [], "edges": []})["nodes"].append(node)
+ before = copy.deepcopy(declarations)
+
+ def forbidden(*_args, **_kwargs):
+ raise AssertionError("Membership projection may not read, discover or execute target sources")
+
+ for method in ("open", "read_bytes", "read_text", "glob", "rglob"):
+ monkeypatch.setattr(Path, method, forbidden)
+ monkeypatch.setattr(builtins, "open", forbidden)
+ monkeypatch.setattr(subprocess, "run", forbidden)
+ monkeypatch.setattr(subprocess, "Popen", forbidden)
+ index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path)
+ sites, _ = resolve_project_memberships(active, result["nodes"], result["edges"], root=tmp_path, project_index=index)
+ assert sites and all(qml_metadata(site)["status"] == "resolved" for site in sites)
+ assert declarations == before
+
+
+@pytest.mark.parametrize("bad_location", ["L10", "L1-L999"])
+def test_req_qml020_ac02_location_prefix_cannot_authorize_another_source_span(tmp_path, bad_location):
+ """A line prefix cannot substitute for the exact transported declaration location."""
+ result = fixture(tmp_path)
+ declaration = facts(result, "resource_alias")[0]
+ assert declaration["source_location"] == "L1"
+ declaration["source_location"] = bad_location
+ with pytest.raises(ValueError, match="QML_METADATA"):
+ apply(tmp_path, result["nodes"], result["edges"], [declaration])
+
+
+def test_req_qml020_ac03_typed_file_role_survives_punctuation_and_borrowed_publication(tmp_path):
+ """Actual publication retains the file label/path while literal metadata survives sanitation."""
+ result = fixture(tmp_path, "qmake", "res&ources.qrc")
+ declaration = next(node for node in facts(result, "qt_source") if qml_metadata(node)["source_kind"] == "qrc")
+ path = tmp_path / "graph.json"
+ assert to_json(build_from_json(result, root=tmp_path), {}, str(path))
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ nodes, edges = raw["nodes"], raw.get("links", raw.get("edges"))
+ target = next(node for node in nodes if node["source_file"] == "res&ources.qrc"
+ and qml_metadata(node).get("kind") == "file")
+ assert target["label"] == "res&ources.qrc"
+ site = apply(tmp_path, nodes, edges, [declaration])[0]
+ assert qml_metadata(site)["status"] == "resolved" and qml_metadata(site)["target_id"] == target["id"]
diff --git a/tests/test_qt_project_membership_aliases.py b/tests/test_qt_project_membership_aliases.py
new file mode 100644
index 0000000000..5d5003134f
--- /dev/null
+++ b/tests/test_qt_project_membership_aliases.py
@@ -0,0 +1,225 @@
+"""REQ-QML-020: real filesystem aliases preserve accepted membership identity."""
+from __future__ import annotations
+
+import copy
+import ctypes
+import os
+from pathlib import Path
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.extract import extract
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.qml_resolution_types import source_path
+from graphify.qt_project_index import QtProjectIndex
+from graphify.qt_project_membership import resolve_project_memberships
+from tests.qml_installed_smoke import native_module_smoke
+from tests.test_qt_final_incremental_parity import clean, normalized, run
+
+
+NAMES = ("backend.h", "Main.qml", "loader.cpp", "CMakeLists.txt", "resources.qrc")
+
+
+@pytest.fixture(params=["windows_short", "symlink"])
+def aliased_parent(tmp_path, request):
+ """Exercise host path aliases without substituting a fake Path resolver."""
+ parent = tmp_path / "parent directory with spaces"
+ parent.mkdir()
+ if request.param == "symlink":
+ request.getfixturevalue("requires_symlinks")
+ alias = tmp_path / "parent-alias"
+ alias.symlink_to(parent, target_is_directory=True)
+ else:
+ if os.name != "nt":
+ pytest.skip("Windows short-path API is not applicable on this host")
+ api = ctypes.windll.kernel32.GetShortPathNameW
+ api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
+ api.restype = ctypes.c_uint32
+ buffer = ctypes.create_unicode_buffer(32768)
+ length = api(str(parent), buffer, len(buffer))
+ if not length or length >= len(buffer) or buffer.value == str(parent):
+ pytest.skip("Filesystem does not supply a distinct Windows short-path alias")
+ alias = Path(buffer.value)
+ assert alias != parent and alias.resolve() == parent.resolve()
+ return parent, alias
+
+
+def extract_graph(root, anchor, cache, extra=()):
+ """The actual facade publishes the mixed-language accepted graph."""
+ result = extract([root / name for name in (*NAMES, *extra)], root=anchor,
+ cache_root=cache, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ return build_from_json(result, root=anchor, directed=True), result
+
+
+def memberships(graph):
+ return {identity: qml_metadata(data) for identity, data in graph.nodes(data=True)
+ if qml_metadata(data).get("kind") == "membership_resolution"}
+
+
+def test_req_qml020_ac01_real_parent_alias_preserves_smoke_and_cold_warm_identity(
+ aliased_parent, tmp_path, monkeypatch, request):
+ """Unchanged smoke and a warm unrelated cache retain identical Qt endpoints."""
+ parent, alias = aliased_parent
+ canonical, supplied = parent / "native-profile", alias / "native-profile"
+ native_module_smoke(supplied)
+ (canonical / "keep.py").write_bytes(b"def retained(): return 7\n")
+ names = (*NAMES, "keep.py")
+ before = {name: (canonical / name).read_bytes() for name in names}
+ expected, _ = extract_graph(canonical, canonical, tmp_path / "canonical-cache", ("keep.py",))
+ cold, cold_result = extract_graph(supplied, supplied, tmp_path / "alias-cache", ("keep.py",))
+ assert normalized(cold) == normalized(expected)
+ sites = memberships(cold)
+ assert len(sites) == 4 and all(md["status"] == "resolved" for md in sites.values())
+
+ # Qt policy deliberately reparses native/QML/metadata facts. The unrelated
+ # Python file must hit its actual cache while these fresh joins retain IDs.
+ import graphify.extract as extraction
+ observed, real = [], extraction._safe_extract_with_xaml_root
+
+ def observe(extractor, path, root):
+ observed.append(path)
+ return real(extractor, path, root)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ warm, warm_result = extract_graph(supplied, supplied, tmp_path / "alias-cache", ("keep.py",))
+ # Native alternate entry spellings expand before dispatch; a separately
+ # discovered symlink keeps its exact lexical owner. Python still hits cache.
+ parser_root = canonical if request.node.callspec.params["aliased_parent"] == "windows_short" else supplied
+ assert set(observed) == {parser_root / name for name in NAMES}
+ assert normalized(warm) == normalized(cold)
+ assert memberships(warm) == sites
+ assert {node["id"] for node in warm_result["nodes"]} == {
+ node["id"] for node in cold_result["nodes"]}
+ reanchored, _ = extract_graph(supplied, canonical, tmp_path / "reanchored-cache", ("keep.py",))
+ assert normalized(reanchored) == normalized(expected) and memberships(reanchored) == sites
+ assert before == {name: (canonical / name).read_bytes() for name in names}
+ # Stored provenance is a lexical, read-free contract. Only accepted input
+ # path identity at the membership boundary should resolve filesystem aliases.
+ assert source_path({"source_file": str(supplied / "CMakeLists.txt")}, canonical) is None
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml020_ac03_alias_updates_remove_stale_membership_and_match_full_rebuild(
+ aliased_parent, tmp_path, monkeypatch, operation):
+ """Real update writers retire a removed build declaration through the supplied alias."""
+ parent, alias = aliased_parent
+ canonical, supplied = parent / "native-profile", alias / "native-profile"
+ native_module_smoke(supplied)
+ monkeypatch.chdir(canonical)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(supplied, monkeypatch, operation)
+ assert normalized(initial) == normalized(clean(canonical, tmp_path / "initial-cache"))
+ old = memberships(initial)
+ assert len(old) == 4 and all(md["status"] == "resolved" for md in old.values())
+ build = supplied / "CMakeLists.txt"
+ build.write_bytes(build.read_bytes().replace(b"QML_FILES Main.qml", b"QML_FILES"))
+ updated = run(supplied, monkeypatch, operation, [build])
+ retired = set(old) - set(memberships(updated))
+ assert len(retired) == 1 and not retired.intersection(updated)
+ assert normalized(updated) == normalized(clean(canonical, tmp_path / "changed-cache"))
+ assert normalized(run(supplied, monkeypatch, operation, [])) == normalized(updated)
+ for source, target, data in updated.edges(data=True):
+ if data.get("context") == "qt_membership_site":
+ assert source not in retired and target not in retired
+
+
+@pytest.mark.parametrize("corruption", ["outside_root", "conflicting_source"])
+def test_req_qml020_ac02_path_alias_cannot_authorize_foreign_source(
+ aliased_parent, tmp_path, corruption):
+ """A real outside/other source path still raises the established integrity diagnostic."""
+ parent, alias = aliased_parent
+ canonical, supplied = parent / "native-profile", alias / "native-profile"
+ native_module_smoke(canonical)
+ _, result = extract_graph(canonical, canonical, tmp_path / "accepted-cache")
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source")
+ if corruption == "outside_root":
+ foreign = parent / "foreign/CMakeLists.txt"
+ foreign.parent.mkdir()
+ foreign.write_bytes((canonical / "CMakeLists.txt").read_bytes())
+ path = alias / "foreign/CMakeLists.txt"
+ else:
+ path = supplied / "other.cmake"
+ path.write_bytes((canonical / "CMakeLists.txt").read_bytes())
+ active = {path: {"nodes": [declaration], "edges": []}}
+ index = QtProjectIndex(nodes, edges, root=canonical)
+ before = copy.deepcopy((nodes, edges, active))
+ with pytest.raises(ValueError, match="QML_METADATA: membership declaration source is not accepted"):
+ resolve_project_memberships(active, nodes, edges, root=canonical, project_index=index)
+ assert (nodes, edges, active) == before
+
+
+@pytest.mark.parametrize("error_type", [OSError, RuntimeError])
+def test_req_qml020_ac02_input_resolution_failure_is_bounded_and_preserves_facts(
+ tmp_path, monkeypatch, error_type):
+ """Filesystem failure/alias loops cannot leak backend bodies or partially publish."""
+ canonical = tmp_path / "native-profile"
+ native_module_smoke(canonical)
+ _, result = extract_graph(canonical, canonical, tmp_path / "accepted-cache")
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ declaration = next(node for node in nodes if qml_metadata(node).get("kind") == "qt_source")
+ build = canonical / "CMakeLists.txt"
+ active = {build: {"nodes": [declaration], "edges": []}}
+ index = QtProjectIndex(nodes, edges, root=canonical)
+ before = copy.deepcopy((nodes, edges, active))
+ real, observed = Path.resolve, []
+ private_body = "private-filesystem-backend-body"
+
+ def fail_input_only(path, *args, **kwargs):
+ # Accepted parsing/indexing is complete. Fail only this supplied input
+ # identity operation; root and all other real path behavior stay intact.
+ if path == build:
+ observed.append(path)
+ raise error_type(private_body)
+ return real(path, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "resolve", fail_input_only)
+ with pytest.raises(ValueError) as failure:
+ resolve_project_memberships(active, nodes, edges, root=canonical, project_index=index)
+ assert str(failure.value) == "QML_METADATA: membership declaration source is not accepted"
+ assert private_body not in repr(failure.value) and failure.value.__cause__ is None
+ assert observed == [build] and (nodes, edges, active) == before
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml020_ac02_conflicting_transport_retains_durable_products_and_recovers(
+ aliased_parent, tmp_path, monkeypatch, operation):
+ """Rejected accepted-input transport preserves saved graph/manifest/stamp/root products."""
+ import graphify.qt_project_membership as membership
+ from graphify.watch import _rebuild_code
+
+ parent, alias = aliased_parent
+ canonical, supplied = parent / "native-profile", alias / "native-profile"
+ native_module_smoke(supplied)
+ monkeypatch.chdir(canonical)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(supplied, monkeypatch, operation)
+ output = canonical / "graphify-out"
+ products = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+ before = {name: (output / name).read_bytes() for name in products}
+ build = supplied / "CMakeLists.txt"
+ source = build.read_bytes()
+ build.write_bytes(source + b"\n# trigger accepted-source refresh\n")
+ real = membership.resolve_project_memberships
+
+ def conflict(results, *args, **kwargs):
+ # Corrupt only the transported input owner. Real parsing, source facts,
+ # per-run indexes, diagnostic handling, and durable writers still run.
+ wrong = {Path(path).with_name("other.cmake"): value for path, value in results.items()}
+ return real(wrong, *args, **kwargs)
+
+ with monkeypatch.context() as rejected:
+ rejected.setattr(membership, "resolve_project_memberships", conflict)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as failure:
+ run(supplied, rejected, operation, [build])
+ assert failure.value.code == 1
+ else:
+ assert not _rebuild_code(supplied, changed_paths=[build], no_cluster=True)
+ assert before == {name: (output / name).read_bytes() for name in products}
+ build.write_bytes(source)
+ repaired = run(supplied, monkeypatch, operation, [build])
+ assert normalized(repaired) == normalized(initial)
+ assert normalized(run(supplied, monkeypatch, operation, [])) == normalized(initial)
diff --git a/tests/test_qt_project_membership_updates.py b/tests/test_qt_project_membership_updates.py
new file mode 100644
index 0000000000..26c070dd5b
--- /dev/null
+++ b/tests/test_qt_project_membership_updates.py
@@ -0,0 +1,299 @@
+"""REQ-QML-020 production refresh, persistence and unused-component regression."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.extractors.qml_facts import qml_metadata
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+from tests.test_qt_graph_html_payload import payload, runtime_info
+
+
+def fixture(root):
+ """An uninstantiated QML component is packaged by accepted literal metadata."""
+ sources = {
+ "Spare.qml": "import QtQuick\nItem { id: grip; property int value: 1 }\n",
+ "helper.cpp": "int helper() { return 7; }\n",
+ "CMakeLists.txt": "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Spare.qml SOURCES helper.cpp)\n",
+ "ui.qrc": 'Spare.qml',
+ "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n",
+ }
+ for name, source in sources.items():
+ (root / name).write_bytes(source.encode())
+ return root / "Spare.qml"
+
+
+def memberships(graph):
+ return {identity: qml_metadata(data) for identity, data in graph.nodes(data=True)
+ if qml_metadata(data).get("kind") == "membership_resolution"}
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml020_ac03_metadata_resource_and_source_updates_remove_stale_memberships(tmp_path, monkeypatch, operation):
+ """Real full/warm/manual/watch graphs agree after metadata edits and target removal."""
+ target = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold-cache")
+ assert normalized(clean(tmp_path, tmp_path / ".cold-cache")) == normalized(cold)
+ graph = run(tmp_path, monkeypatch, operation)
+ assert normalized(graph) == normalized(cold)
+ original = memberships(graph)
+ assert len(original) == 3 and all(md["status"] == "resolved" for md in original.values())
+ module = next(identity for identity, node in graph.nodes(data=True) if qml_metadata(node).get("kind") == "qt_module")
+ component = next(identity for identity, node in graph.nodes(data=True) if qml_metadata(node).get("kind") == "component")
+ import networkx as nx
+ assert nx.has_path(graph, module, component)
+ kept = unrelated(graph)
+
+ # Renaming a resource alias replaces its source-owned resolution site,
+ # without changing the accepted QML component identity or unrelated Python.
+ resource = tmp_path / "ui.qrc"
+ resource.write_bytes(resource.read_bytes().replace(b'alias="Spare.qml"', b'alias="renamed.qml"'))
+ renamed = run(tmp_path, monkeypatch, operation, [resource])
+ retired = set(original) - set(memberships(renamed))
+ assert len(retired) == 1 and not retired.intersection(renamed)
+ assert component in renamed and unrelated(renamed) == kept
+ assert normalized(renamed) == normalized(clean(tmp_path, tmp_path / ".renamed-cache"))
+
+ # Duplicate logical aliases invalidate both resource endpoint decisions,
+ # even when both literal declarations still name the same accepted target.
+ valid_resource = resource.read_bytes()
+ resource.write_bytes(valid_resource.replace(b"",
+ b'Spare.qml'))
+ ambiguous = run(tmp_path, monkeypatch, operation, [resource])
+ resource_sites = [md for md in memberships(ambiguous).values() if md.get("alias")]
+ assert len(resource_sites) == 2 and all(md["status"] == "ambiguous" for md in resource_sites)
+ assert all(not md.get("target_id") for md in resource_sites)
+ assert normalized(ambiguous) == normalized(clean(tmp_path, tmp_path / ".ambiguous-cache"))
+ resource.write_bytes(valid_resource)
+ assert normalized(run(tmp_path, monkeypatch, operation, [resource])) == normalized(renamed)
+
+ # Editing and then renaming the packaged source updates its canonical facts
+ # and both metadata paths; old endpoints cannot survive their retirement.
+ target.write_bytes(target.read_bytes().replace(b"value: 1", b"value: 2"))
+ edited = run(tmp_path, monkeypatch, operation, [target])
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited-cache"))
+ assert unrelated(edited) == kept
+ old_target = target
+ target = tmp_path / "Renamed.qml"
+ old_target.rename(target)
+ build = tmp_path / "CMakeLists.txt"
+ build.write_bytes(build.read_bytes().replace(b"Spare.qml", b"Renamed.qml"))
+ resource.write_bytes(resource.read_bytes().replace(b">Spare.qml<", b">Renamed.qml<"))
+ renamed_target = run(tmp_path, monkeypatch, operation, [old_target, target, build, resource])
+ assert component not in renamed_target
+ component = next(identity for identity, node in renamed_target.nodes(data=True)
+ if qml_metadata(node).get("kind") == "component")
+ assert all(md["status"] == "resolved" for md in memberships(renamed_target).values())
+ assert normalized(renamed_target) == normalized(clean(tmp_path, tmp_path / ".target-renamed-cache"))
+ assert unrelated(renamed_target) == kept
+
+ # Removing an accepted target invalidates unchanged module/alias statements;
+ # no fallback to a same-named source or external read can retain their edges.
+ target.unlink()
+ removed = run(tmp_path, monkeypatch, operation, [target])
+ assert component not in removed
+ remaining = memberships(removed)
+ missing = [md for md in remaining.values() if md.get("status") != "resolved"]
+ assert len(missing) == 2 and all(not md.get("target_id") for md in missing)
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cache"))
+ assert unrelated(removed) == kept
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(removed)
+
+
+@pytest.mark.parametrize("operation", ["manual", "force", "watch"])
+def test_req_qml020_ac02_failed_metadata_join_preserves_products_and_recovers(tmp_path, monkeypatch, operation):
+ """An actual malformed resource preserves four durable products; byte repair is repeatable."""
+ fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ mode = "manual" if operation == "force" else operation
+ initial = run(tmp_path, monkeypatch, mode)
+ assert memberships(initial)
+ output = tmp_path / "graphify-out"
+ names = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+ before = {name: (output / name).read_bytes() for name in names}
+ path = tmp_path / "ui.qrc"
+ valid = path.read_bytes()
+ path.write_bytes(valid + b"")
+ if operation in {"manual", "force"}:
+ with pytest.raises(SystemExit) as failure:
+ if operation == "force":
+ from tests.test_qt_cpp_upgrade_invalidation import cli
+ cli(tmp_path, monkeypatch, "update", force=True)
+ else:
+ run(tmp_path, monkeypatch, mode, [path])
+ assert failure.value.code == 1
+ else:
+ from graphify.watch import _rebuild_code
+ assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True)
+ assert before == {name: (output / name).read_bytes() for name in names}
+ path.write_bytes(valid)
+ corrected = run(tmp_path, monkeypatch, mode, [path])
+ assert normalized(corrected) == normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(initial)
+ assert json.loads((output / "graph.json").read_text(encoding="utf-8"))["nodes"]
+
+
+@pytest.mark.parametrize("operation", ["extract", "update"])
+def test_req_qml020_ac03_policy_upgrade_refreshes_unchanged_packaging(tmp_path, monkeypatch, operation):
+ """A policy-five stamp cannot retain old decisions at the same package version."""
+ import graphify.extract as extraction
+ import graphify.qt_incremental as policy
+ from graphify.qt_analysis_state import inspect_qt_analysis
+ from tests.test_qt_cpp_upgrade_invalidation import cli
+
+ fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ paths = [tmp_path / name for name in ("Spare.qml", "helper.cpp", "CMakeLists.txt", "ui.qrc", "keep.py")]
+ with monkeypatch.context() as previous:
+ previous.setattr(policy, "QT_POLICY_VERSION", 5)
+ before = cli(tmp_path, previous, "extract")
+ assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed
+ sources = {path: path.read_bytes() for path in paths}
+ assert inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed
+ observed, real = [], extraction._safe_extract_with_xaml_root
+
+ def observe(extractor, path, root):
+ observed.append(path)
+ return real(extractor, path, root)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ current = cli(tmp_path, monkeypatch, operation)
+ assert set(paths[:4]).issubset(observed)
+ assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed
+ assert len(memberships(current)) == 3
+ assert normalized(current) == normalized(before)
+ assert normalized(current) == normalized(clean(tmp_path, tmp_path / ".upgrade-cache"))
+ assert sources == {path: path.read_bytes() for path in paths}
+ assert normalized(cli(tmp_path, monkeypatch, operation)) == normalized(current)
+
+
+def test_req_qml020_ac02_join_exception_cannot_publish_partial_memberships(tmp_path, monkeypatch):
+ """A failure after real scratch projection preserves graph, manifest, stamp and root."""
+ import graphify.qt_project_membership as membership
+
+ fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, "manual")
+ output = tmp_path / "graphify-out"
+ products = [output / name for name in ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")]
+ before = {path: path.read_bytes() for path in products}
+ path = tmp_path / "CMakeLists.txt"
+ original = path.read_bytes()
+ path.write_bytes(original + b"\n# refresh projection\n")
+ real = membership.resolve_project_memberships
+
+ def fail_after_real_join(*args, **kwargs):
+ result = real(*args, **kwargs)
+ assert result[0]
+ raise RuntimeError("injected join completion failure")
+
+ with monkeypatch.context() as failure:
+ failure.setattr(membership, "resolve_project_memberships", fail_after_real_join)
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failure, "manual", [path])
+ assert rejected.value.code == 1
+ assert before == {path: path.read_bytes() for path in products}
+ path.write_bytes(original)
+ assert normalized(run(tmp_path, monkeypatch, "manual", [path])) == normalized(initial)
+
+
+def test_req_qml020_ac01_unused_packaged_component_survives_aggregate_export(tmp_path, monkeypatch):
+ """Actual serialized facts yield aggregate links and truthful source-edge counts."""
+ from graphify.export import to_html
+
+ fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ graph = run(tmp_path, monkeypatch, "manual")
+ files = sorted({node["source_file"] for _, node in graph.nodes(data=True)})
+ groups = {number: [identity for identity, node in graph.nodes(data=True) if node["source_file"] == path]
+ for number, path in enumerate(files)}
+ number = files.index("Spare.qml")
+ path = tmp_path / "aggregate.html"
+ assert to_html(graph, groups, str(path), node_limit=1, learning_overlay={})
+ content = path.read_text(encoding="utf-8")
+ result = {"nodes": payload(content, "RAW_NODES"), "info": runtime_info(content, str(number), monkeypatch)}
+ selected = next(node for node in result["nodes"] if node["id"] == str(number))
+ assert selected["external_source_edges"] == 2 and selected["degree"] == 2
+ assert selected["internal_source_edges"] > 0
+ # Startup selection belongs to deferred REQ-QML-019-AC04; source counts remain required.
+ assert "External source edges: 2" in result["info"]
+ assert sum(node["internal_source_edges"] for node in result["nodes"]) + sum(
+ node["external_source_edges"] for node in result["nodes"]) // 2 == graph.number_of_edges()
+
+
+@pytest.mark.parametrize("invalid", [True, -1, ""])
+def test_req_qml020_ac01_aggregate_counts_keep_source_edges_and_reject_invalid_fields(tmp_path, monkeypatch, invalid):
+ """Parallel/directed/self-loop edges count once; untrusted aggregate fields remain unavailable."""
+ import copy
+ import networkx as nx
+ from graphify.export import to_html
+
+ graph = nx.MultiDiGraph()
+ graph.add_edges_from([("a0", "a1"), ("a1", "a0"), ("a0", "a0"), ("a0", "b0"),
+ ("b0", "a0"), ("a0", "a1"), ("a1", "b0")])
+ before = copy.deepcopy(graph)
+ path = tmp_path / "counts.html"
+ assert to_html(graph, {0: ["a0", "a1"], 1: ["b0"]}, str(path), node_limit=1, learning_overlay={})
+ nodes = {node["id"]: node for node in payload(path.read_text(encoding="utf-8"), "RAW_NODES")}
+ assert nodes["0"]["internal_source_edges"] == 4 and nodes["1"]["internal_source_edges"] == 0
+ assert nodes["0"]["external_source_edges"] == nodes["1"]["external_source_edges"] == 3
+ assert sum(n["internal_source_edges"] for n in nodes.values()) + sum(
+ n["external_source_edges"] for n in nodes.values()) // 2 == graph.number_of_edges()
+ assert nx.utils.graphs_equal(graph, before)
+ meta = nx.Graph([("0", "1")])
+ meta.nodes["0"].update(internal_source_edges=invalid, external_source_edges=invalid)
+ assert to_html(meta, {0: ["0"], 1: ["1"]}, str(path), member_counts={0: 4, 1: 2}, learning_overlay={})
+ content = path.read_text(encoding="utf-8")
+ assert all("internal_source_edges" not in n and "external_source_edges" not in n
+ for n in payload(content, "RAW_NODES"))
+ info = runtime_info(content, "0", monkeypatch)
+ assert "Internal source edges: unavailable" in info and "External source edges: unavailable" in info
+ assert "bad()" not in info and "Degree: 1" in info
+
+
+def test_req_qml020_ac03_membership_ids_are_independent_of_checkout_location(tmp_path):
+ """Accepted relative metadata and QML facts remain identical after root relocation."""
+ first, second = tmp_path / "first", tmp_path / "relocated"
+ first.mkdir()
+ second.mkdir()
+ fixture(first)
+ fixture(second)
+ original = clean(first, tmp_path / ".first-cache")
+ relocated = clean(second, tmp_path / ".second-cache")
+ assert len(memberships(original)) == 3
+ assert normalized(original) == normalized(relocated)
+
+
+def test_req_qml020_ac03_pipeline_publishes_replacement_without_borrowed_mutation(tmp_path):
+ """Rejoining fresh metadata replaces a borrowed site before the former list boundary."""
+ import copy
+ from graphify.extract import extract
+ from graphify.extractors.qml_resources import extract_qrc
+ from graphify.qt_qml_pipeline import resolve_qt_qml
+
+ fixture(tmp_path)
+ paths = [tmp_path / name for name in ("Spare.qml", "helper.cpp", "CMakeLists.txt", "ui.qrc")]
+ prior = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ old_site = next(node for node in prior["nodes"] if qml_metadata(node).get("kind") == "membership_resolution"
+ and qml_metadata(node).get("alias"))
+ old_id = old_site["id"]
+ context = [node for node in prior["nodes"] if node["source_file"] != "Spare.qml"]
+ context_ids = {node["id"] for node in context}
+ context_edges = [edge for edge in prior["edges"]
+ if edge["source"] in context_ids and edge["target"] in context_ids]
+ before = copy.deepcopy((context, context_edges))
+ fresh = extract_qrc(paths[-1], root=tmp_path)
+ nodes, edges = list(fresh["nodes"]), list(fresh["edges"])
+ assert not resolve_qt_qml([paths[-1]], [fresh], nodes, edges, root=tmp_path,
+ context_nodes=context, context_edges=context_edges)
+ replacement = next(node for node in nodes if node["id"] == old_id)
+ assert qml_metadata(replacement)["status"] == "unavailable"
+ assert qml_metadata(old_site)["status"] == "resolved"
+ assert (context, context_edges) == before
+ assert not any(edge["source"] == old_id for edge in edges)
+ assert {node["source_file"] for node in nodes} == {"ui.qrc"}
+ assert sum(node["id"] == old_id for node in nodes) == 1
diff --git a/tests/test_qt_project_metadata.py b/tests/test_qt_project_metadata.py
new file mode 100644
index 0000000000..c68db9ba64
--- /dev/null
+++ b/tests/test_qt_project_metadata.py
@@ -0,0 +1,146 @@
+"""QML-009-AC01/04: literal build provenance and fail-closed scope boundaries."""
+import json
+
+import pytest
+
+from graphify.extractors.qml_cmake import extract_cmake, parse_cmake
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_project_read import MAX_PROJECT_BYTES
+from graphify.extractors.qml_qmake import extract_qmake, parse_qmake
+from graphify.security import sanitize_metadata
+
+
+def records(result, kind):
+ return [qml_metadata(node) for node in result["nodes"] if qml_metadata(node).get("kind") == kind]
+
+
+@pytest.mark.parametrize("newline", ["\n", "\r\n"])
+@pytest.mark.parametrize("bom", ["", "\ufeff"])
+def test_literal_cmake_records_original_bytes_and_transport(tmp_path, newline, bom):
+ """Both Qt command names preserve URI/version, Unicode paths and exact ranges."""
+ source = bom + newline.join([
+ "# α", "cmake_minimum_required(VERSION 3.21)", "project(PublicTools)",
+ "qt6_add_qml_module(tools", " URI Public.Tools VERSION 1.2 RESOURCE_PREFIX /ui",
+ ' QML_FILES Main.qml "Écran.qml" SOURCES service.h service.cpp',
+ " RESOURCES icon.svg IMPORTS Public.Base/1.0 QtQuick/auto",
+ " DEPENDENCIES Public.Core/1 OPTIONAL_IMPORTS Public.Optional",
+ " IMPORT_PATH imports NO_PLUGIN)", "",])
+ path = tmp_path / "CMakeLists.txt"
+ path.write_bytes(source.encode("utf-8"))
+ result = extract_cmake(path, root=tmp_path)
+ assert not result.get("error")
+ module = records(result, "qt_module")[0]
+ assert (module["uri"], module["major"], module["minor"], module["resource_prefix"]) == (
+ "Public.Tools", 1, 2, "/ui")
+ assert [md["value"] for md in records(result, "qt_source")] == [
+ "Main.qml", "Écran.qml", "service.h", "service.cpp", "icon.svg"]
+ imports = records(result, "qt_module_import")
+ assert [(md["uri"], md["visibility"]) for md in imports] == [
+ ("Public.Base", "import"), ("QtQuick", "import"),
+ ("Public.Core", "dependencies"), ("Public.Optional", "optional_imports")]
+ assert imports[1]["auto"] is True
+ raw = source.encode("utf-8")
+ for node in result["nodes"]:
+ md = qml_metadata(node)
+ span = md["span"]
+ for end in ("start", "end"):
+ prefix = raw[:span[f"{end}_byte"]]
+ assert span[f"{end}_row"] == prefix.count(b"\n")
+ assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1
+ if md["kind"] == "qt_source":
+ assert raw[span["start_byte"]:span["end_byte"]].decode().strip('"') == md["value"]
+ sanitized = json.loads(json.dumps(result))
+ for item in sanitized["nodes"] + sanitized["edges"]:
+ item["metadata"] = sanitize_metadata(item["metadata"])
+ assert records(sanitized, "qt_source") == records(result, "qt_source")
+ assert {edge["source"] for edge in result["edges"]} <= {node["id"] for node in result["nodes"]}
+
+
+@pytest.mark.parametrize("source,reason", [
+ ("if(WIN32)\nqt_add_qml_module(app URI Hidden VERSION 1.0 QML_FILES A.qml)\nendif()", "conditional_or_expanded_qml_module"),
+ ("function(build)\nqt_add_qml_module(app URI Hidden VERSION 1.0)\nendfunction()", "conditional_or_expanded_qml_module"),
+ ("qt_add_qml_module(app URI Public.Tools VERSION ${VERSION})", "conditional_or_expanded_qml_module"),
+ ("qt_add_qml_module(app URI Public.Tools SOURCES $)", "conditional_or_expanded_qml_module"),
+ ("qt_add_qml_module(app URI Public.Tools VERSION 1.0 IMPORTS TARGET Other)", "target_import_requires_policy_and_target_resolution"),
+ ("qt_add_qml_module(app URI Public.Tools UNKNOWN feature)", "unsupported_qml_module_arguments"),
+ ("qt_add_qml_module(app URI Public.Tools VERSION 1.0)\ntarget_sources(app PRIVATE generated.h)", "source_mutation_outside_literal_subset"),
+])
+def test_cmake_conditional_and_expanded_metadata_is_not_authoritative(source, reason):
+ result = parse_cmake(source)
+ assert result["partial"] is True
+ assert reason in {item["reason"] for item in result["diagnostics"]}
+ assert result["qml_failures"]
+ if "Hidden" in source:
+ assert records(result, "qt_module") == []
+
+
+def test_comments_strings_and_unknown_builds_do_not_make_modules():
+ source = '# qt_add_qml_module(fake URI Fake)\nmessage("qt_add_qml_module(fake URI Fake)")\n'
+ source += '#[[ qt_add_qml_module(fake URI Fake) ]]\nproject(Ordinary)\n'
+ result = parse_cmake(source)
+ assert not result.get("error")
+ assert records(result, "qt_module") == []
+ assert parse_cmake("if(WIN32)\nproject(Ordinary)")["qml_failures"]
+
+
+def test_qmake_literal_module_sources_resource_and_tooling_paths(tmp_path):
+ source = '\ufeff# α\r\nQT += qml quick\r\nCONFIG += qmltypes\r\nTARGET = tools\r\n'
+ source += 'QML_IMPORT_NAME = Public.Tools\r\nQML_IMPORT_VERSION = 1.2\r\n'
+ source += 'SOURCES += service.cpp \\\r\n worker.cpp\r\nHEADERS += service.h\r\n'
+ source += 'RESOURCES += ui.qrc\r\nQML_IMPORT_PATH += imports\r\nDISTFILES += "Écran.qml"\r\n'
+ path = tmp_path / "tools.pro"
+ path.write_bytes(source.encode())
+ result = extract_qmake(path, root=tmp_path)
+ assert not result.get("error")
+ module = records(result, "qt_module")[0]
+ assert (module["uri"], module["major"], module["minor"]) == ("Public.Tools", 1, 2)
+ assert [md["value"] for md in records(result, "qt_source")] == [
+ "service.cpp", "worker.cpp", "service.h", "Écran.qml", "ui.qrc"]
+ assert records(result, "qt_import_path")[0]["visibility"] == "tooling"
+ assert result == parse_qmake(source, relative_file="tools.pro")
+
+
+@pytest.mark.parametrize("line", [
+ "unix {\n QML_IMPORT_NAME = Hidden\n}", "win32: QML_IMPORT_NAME = Hidden",
+ "QML_IMPORT_NAME = $$MODULE", "include(other.pri)",
+ "QML_IMPORT_NAME = Public.Tools\nSOURCES += $$files(*.cpp)",
+ "QML_IMPORT_NAME = Public.Tools\nSOURCES -= service.cpp",
+])
+def test_qmake_conditions_expansion_functions_never_produce_guessed_context(line):
+ result = parse_qmake("QML_IMPORT_MAJOR_VERSION = 1\n" + line)
+ assert result["qml_failures"]
+ assert records(result, "qt_module") == []
+
+
+def test_qmake_assignment_semantics_and_evidenced_base_minor():
+ result = parse_qmake("QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_MAJOR_VERSION = 1\n"
+ "SOURCES = old.cpp\nSOURCES = new.cpp\nSOURCES += helper.cpp")
+ module = records(result, "qt_module")[0]
+ assert module["minor"] == 0
+ assert module["version_origin"] == "base_major_registration"
+ assert module["minor_upper_bound_known"] is False
+ assert [md["value"] for md in records(result, "qt_source")] == ["new.cpp", "helper.cpp"]
+ for source in ("QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = 1.0\nQML_IMPORT_MAJOR_VERSION = 2",
+ "QML_IMPORT_NAME = A\nQML_IMPORT_VERSION = bogus"):
+ assert parse_qmake(source)["qml_failures"]
+
+
+@pytest.mark.parametrize("reader,name", [(extract_cmake, "CMakeLists.txt"), (extract_qmake, "tools.pro")])
+def test_build_reader_containment_utf8_and_actual_read_limits(tmp_path, monkeypatch, reader, name):
+ root = tmp_path / "root"
+ root.mkdir()
+ outside = tmp_path / name
+ outside.write_bytes(b"project(Outside)")
+ assert reader(outside, root=root)["nodes"] == []
+ path = root / name
+ path.write_bytes(b"\xff")
+ assert reader(path, root=root)["qml_failures"]
+ path.write_bytes(b"x" * (MAX_PROJECT_BYTES + 1))
+ assert reader(path, root=root)["diagnostics"][0]["reason"] == "metadata_size_limit"
+
+
+def test_cmake_ids_are_comment_stable_and_case_distinct():
+ source = "qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml main.qml)"
+ before, after = parse_cmake(source), parse_cmake("# comment\n" + source)
+ assert [n["id"] for n in before["nodes"]] == [n["id"] for n in after["nodes"]]
+ assert len({n["id"] for n in before["nodes"]}) == len(before["nodes"])
diff --git a/tests/test_qt_publication_stub_origin.py b/tests/test_qt_publication_stub_origin.py
new file mode 100644
index 0000000000..39855785fb
--- /dev/null
+++ b/tests/test_qt_publication_stub_origin.py
@@ -0,0 +1,168 @@
+"""INC-QML-38: raw publication owns provenance of only its newly minted stubs."""
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+import graphify.watch as watch
+from graphify.cache import cache_dir
+from tests.test_qt_initial_repeat import pin_graph_mtime, update
+
+
+PRODUCTS = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+
+
+def corpus(root, language, *, imported=True):
+ """Write a genuinely cold public corpus; fixture setup never extracts it."""
+ if language == "cpp":
+ (root / "Main.qml").write_bytes(b"import QtQuick\nItem {}\n")
+ (root / "CMakeLists.txt").write_bytes(
+ b"qt_add_qml_module(app URI Public.Tools VERSION 1.0 "
+ b"QML_FILES Main.qml SOURCES backend.cpp)\n")
+ path = root / "backend.cpp"
+ source = b'#include "missing.hpp"\n' if imported else b""
+ path.write_bytes(source + b"int original() { return 7; }\n")
+ else:
+ path = root / "keep.py"
+ source = b"import missing\n" if imported else b""
+ path.write_bytes(source + b"def original(): return 7\n")
+ return path
+
+
+def payload(root):
+ return json.loads((root / "graphify-out/graph.json").read_bytes())
+
+
+def nodes(root):
+ return {node["id"]: node for node in payload(root)["nodes"]}
+
+
+def prepare(root, monkeypatch, language, *, imported=True):
+ source = corpus(root, language, imported=imported)
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ assert not (root / "graphify-out/graph.json").exists()
+ assert not list(cache_dir(root).rglob("*.json"))
+ return source
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("language", ["cpp", "python"])
+def test_first_raw_stub_has_explicit_semantic_origin(tmp_path, monkeypatch, operation, language):
+ """Canonical import minting stamps its sourceless placeholder on the first publication."""
+ prepare(tmp_path, monkeypatch, language)
+ assert update(tmp_path, monkeypatch, operation)
+ published = nodes(tmp_path)
+ assert published["missing"] == {
+ "id": "missing", "label": "missing", "type": "external", "external": True,
+ "file_type": "concept", "source_file": "", "_origin": "semantic"}
+ source_nodes = [node for node in published.values() if node.get("source_file")]
+ assert source_nodes and all(node.get("_origin") == "ast" for node in source_nodes)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("language", ["cpp", "python"])
+def test_first_raw_import_repeat_preserves_bytes_mtime_and_genuine_edit(tmp_path, monkeypatch,
+ operation, language):
+ """An unchanged refresh is durable-idempotent; a real source rename still publishes."""
+ source = prepare(tmp_path, monkeypatch, language)
+ assert update(tmp_path, monkeypatch, operation)
+ graph = tmp_path / "graphify-out/graph.json"
+ original, timestamp = pin_graph_mtime(graph)
+ assert update(tmp_path, monkeypatch, operation)
+ assert graph.read_bytes() == original and graph.stat().st_mtime_ns == timestamp
+ source.write_bytes(source.read_bytes().replace(b"original", b"renamed"))
+ assert update(tmp_path, monkeypatch, operation, [source])
+ assert graph.read_bytes() != original and graph.stat().st_mtime_ns != timestamp
+ assert any("renamed" in node["label"] for node in nodes(tmp_path).values())
+ current, timestamp = pin_graph_mtime(graph)
+ assert update(tmp_path, monkeypatch, operation)
+ assert graph.read_bytes() == current and graph.stat().st_mtime_ns == timestamp
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("origin", [None, "manual", "semantic", "ast"])
+def test_existing_authored_endpoint_is_not_stamped_by_minting(tmp_path, monkeypatch, operation, origin):
+ """Only appended builder nodes are owned here; an existing authored endpoint retains its role."""
+ source = prepare(tmp_path, monkeypatch, "cpp")
+ assert update(tmp_path, monkeypatch, operation)
+ graph = tmp_path / "graphify-out/graph.json"
+ existing = payload(tmp_path)
+ authored = {"id": "authored", "label": "authored", "type": "external", "external": True,
+ "file_type": "concept", "source_file": "", "_origin": origin,
+ "metadata": {"ownership": "authored"}}
+ existing["nodes"].append(authored)
+ graph.write_text(json.dumps(existing), encoding="utf-8")
+ source.write_bytes(b'#include "authored.hpp"\n#include "created.hpp"\nint original() { return 7; }\n')
+ assert update(tmp_path, monkeypatch, operation, [source])
+ published = nodes(tmp_path)
+ assert published["authored"] == authored
+ assert published["created"]["_origin"] == "semantic"
+ # The previous import loses its only accepted source reference (INC35).
+ assert "missing" not in published
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_first_stamped_cpp_import_deletion_restoration_and_repeat(tmp_path, monkeypatch, operation):
+ """First-publication provenance remains eligible for exact prior-import orphan cleanup."""
+ source = prepare(tmp_path, monkeypatch, "cpp")
+ original = source.read_bytes()
+ assert update(tmp_path, monkeypatch, operation)
+ first = payload(tmp_path)
+ source.unlink()
+ assert update(tmp_path, monkeypatch, operation, [source])
+ assert "missing" not in nodes(tmp_path)
+ source.write_bytes(original)
+ assert update(tmp_path, monkeypatch, operation, [source])
+ # Run metadata is transient, but all original node/edge facts must return.
+ assert payload(tmp_path)["nodes"] == first["nodes"]
+ assert payload(tmp_path)["links"] == first["links"]
+ graph = tmp_path / "graphify-out/graph.json"
+ restored, timestamp = pin_graph_mtime(graph)
+ assert update(tmp_path, monkeypatch, operation)
+ assert graph.read_bytes() == restored and graph.stat().st_mtime_ns == timestamp
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("language", ["cpp", "python"])
+def test_new_stub_replacement_failure_retains_cohort_cache_then_retries(tmp_path, monkeypatch,
+ operation, language):
+ """Real replacement failure cannot accept a newly stamped graph; retry commits once."""
+ source = prepare(tmp_path, monkeypatch, language, imported=False)
+ # Qt refreshes may omit per-file AST cache admission; a real unchanged
+ # Python file supplies durable cache bytes rather than a vacuous snapshot.
+ (tmp_path / "unrelated.py").write_bytes(b"def untouched(): return 9\n")
+ assert update(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ graph = output / "graph.json"
+ pin_graph_mtime(graph)
+ before = {name: ((output / name).read_bytes(), (output / name).stat().st_mtime_ns) for name in PRODUCTS}
+ caches = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")}
+ assert caches and "missing" not in nodes(tmp_path)
+ corpus(tmp_path, language)
+ replace = watch.os_replace_with_fallback
+
+ def reject_graph(staged, destination, **kwargs):
+ if os.fspath(destination).endswith("graph.json"):
+ raise OSError("simulated graph replacement failure")
+ return replace(staged, destination, **kwargs)
+
+ with monkeypatch.context() as failure:
+ failure.setattr(watch, "os_replace_with_fallback", reject_graph)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ update(tmp_path, monkeypatch, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not update(tmp_path, monkeypatch, operation, [source])
+ assert all((output / name).read_bytes() == data and (output / name).stat().st_mtime_ns == mtime
+ for name, (data, mtime) in before.items())
+ assert all(path.exists() and path.read_bytes() == data for path, data in caches.items())
+ assert not list(output.glob(".gfy-publish-*"))
+ assert update(tmp_path, monkeypatch, operation, [source])
+ assert nodes(tmp_path)["missing"]["_origin"] == "semantic"
+ accepted, timestamp = pin_graph_mtime(graph)
+ assert update(tmp_path, monkeypatch, operation)
+ assert graph.read_bytes() == accepted and graph.stat().st_mtime_ns == timestamp
diff --git a/tests/test_qt_qmake_adoption.py b/tests/test_qt_qmake_adoption.py
new file mode 100644
index 0000000000..34dc75b364
--- /dev/null
+++ b/tests/test_qt_qmake_adoption.py
@@ -0,0 +1,156 @@
+"""REQ-QML-018-AC01: source-owned qmake paths never imply runtime roots."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_qmake import extract_qmake, parse_qmake
+from graphify.security import sanitize_metadata
+
+
+BASE = "QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.0\n"
+
+
+def records(result, kind):
+ return [qml_metadata(node) for node in result["nodes"]
+ if qml_metadata(node).get("kind") == kind]
+
+
+@pytest.mark.parametrize("newline", ["\n", "\r\n"])
+@pytest.mark.parametrize("bom", ["", "\ufeff"])
+def test_req_qml018_ac01_pwd_paths_preserve_current_file_and_original_token_spans(tmp_path, newline, bom):
+ """Quoted Unicode and continued paths retain literal text and byte boundaries."""
+ directory = tmp_path / "app"
+ directory.mkdir()
+ source = bom + (BASE + 'SOURCES += $$PWD/service.cpp \\\n "$$PWD/Écran.cpp"\n'
+ 'QML_FILES += $$PWD/ui/Main.qml\n'
+ 'QML_IMPORT_PATH += $$PWD/ui/Public/Tools\n'
+ 'QMLPATHS += $$PWD/ui\n').replace("\n", newline)
+ raw = source.encode()
+ path = directory / "tools.pro"
+ path.write_bytes(raw)
+ result = extract_qmake(path, root=tmp_path)
+ assert not result.get("error")
+ paths = records(result, "qt_source") + records(result, "qt_import_path")
+ assert [md["value"] for md in paths] == [
+ "service.cpp", "Écran.cpp", "ui/Main.qml", "ui/Public/Tools", "ui"]
+ for md in paths:
+ assert md["path_origin"] == "current_file_pwd"
+ span = md["span"]
+ lexeme = raw[span["start_byte"]:span["end_byte"]].decode()
+ assert lexeme.strip('"') == md["source_value"]
+ for point in ("start", "end"):
+ prefix = raw[:span[f"{point}_byte"]]
+ assert span[f"{point}_row"] == prefix.count(b"\n")
+ assert span[f"{point}_column"] == len(prefix) - prefix.rfind(b"\n") - 1
+ imports = records(result, "qt_import_path")
+ assert [md["visibility"] for md in imports] == ["tooling", "build"]
+ assert [md["qmake_variable"] for md in imports] == ["QML_IMPORT_PATH", "QMLPATHS"]
+ transported = json.loads(json.dumps(result))
+ for item in transported["nodes"] + transported["edges"]:
+ item["metadata"] = sanitize_metadata(item["metadata"])
+ assert records(transported, "qt_source") == records(result, "qt_source")
+ assert result == parse_qmake(raw, relative_file="app/tools.pro")
+
+
+def test_req_qml018_ac01_unrelated_conditional_build_settings_do_not_hide_module():
+ """Ignored toolchain settings do not make unconditional membership uncertain."""
+ source = BASE + """QT += qml quick
+CONFIG += c++17
+win32: LIBS += $$PWD/vendor/lib.lib
+unix {
+ QMAKE_CXXFLAGS += -Wall
+ contains(CONFIG, release) {
+ DEFINES += RELEASE_BUILD
+ }
+}
+QML_FILES += $$PWD/Main.qml
+"""
+ result = parse_qmake(source)
+ assert not result.get("error")
+ assert records(result, "qt_module")[0]["uri"] == "Public.Tools"
+ assert records(result, "qt_source")[0]["value"] == "Main.qml"
+ assert records(result, "qt_qmake_statement")
+
+
+@pytest.mark.parametrize("statement", [
+ "win32: QML_FILES += $$PWD/Hidden.qml",
+ "unix {\n QML_FILES += $$PWD/Hidden.qml\n}",
+ "contains(CONFIG, quick): QML_IMPORT_NAME = Hidden",
+])
+def test_req_qml018_ac01_relevant_conditions_remain_source_owned_uncertainty(statement):
+ """A relevant unevaluated branch is inspectable and never authoritative."""
+ result = parse_qmake(BASE + "QML_FILES += $$PWD/Visible.qml\n" + statement)
+ assert result["partial"] and result["qml_failures"]
+ assert not records(result, "qt_module")
+ coverage = records(result, "qt_qmake_statement")
+ assert any(md.get("status") == "unresolved" and md.get("conditional") for md in coverage)
+ assert any(md.get("qmake_variable") == "QML_FILES" and md.get("status") == "observed"
+ for md in records(result, "qt_qmake_assignment"))
+
+
+@pytest.mark.parametrize("value", [
+ "$$OTHER/Main.qml", "$${PWD}/Main.qml", "$$PWDtail/Main.qml",
+ "$$PWD/$$files(*.qml)", "$$PWD/../../Outside.qml", "C:/Outside.qml",
+ "/Outside.qml", "$$PWD/../../Outside.qml", "$$PWD/../x/../../Outside.qml",
+])
+def test_req_qml018_ac06_unsupported_or_escaping_paths_never_gain_authority(value):
+ """Only a lexical current-file prefix within the accepted root is admitted."""
+ result = parse_qmake(BASE + f"QML_FILES += {value}\n", relative_file="app/tools.pro")
+ assert result["qml_failures"]
+ assert not records(result, "qt_module")
+
+
+@pytest.mark.parametrize("statement", [
+ "include(other.pri)", "load(qmltypes)", "system(echo no)",
+ "QML_FILES -= $$PWD/Main.qml", "QMLPATHS += $$unknown_path", "PWD = elsewhere",
+])
+def test_req_qml018_ac06_unknown_mutations_and_pwd_overwrite_are_explicit(statement):
+ """Unknown mutation cannot be repaired by running a qmake hook or include."""
+ result = parse_qmake(BASE + statement)
+ assert result["qml_failures"]
+ assert not records(result, "qt_module")
+
+
+def test_req_qml018_ac01_standalone_import_roles_do_not_create_modules():
+ """A normal application project may expose build/tool hints without a module."""
+ result = parse_qmake("QT += qml quick\nQML_IMPORT_PATH += $$PWD/Public/Tools\n"
+ "QMLPATHS += $$PWD/imports\n", relative_file="app/project.pri")
+ assert not result.get("error") and not records(result, "qt_module")
+ imports = records(result, "qt_import_path")
+ assert [(md["value"], md["visibility"], md["module_key"]) for md in imports] == [
+ ("Public/Tools", "tooling", None), ("imports", "build", None)]
+
+
+def test_req_qml018_ac01_else_unrelated_scope_remains_non_authoritative():
+ """Else scopes retain balance; no platform condition is selected."""
+ result = parse_qmake(BASE + "win32 {\n LIBS += native.lib\n} else {\n LIBS += -lnative\n}\n")
+ assert not result.get("error") and records(result, "qt_module")
+
+
+@pytest.mark.parametrize("value", ['"Main.qml"suffix', 'prefix"Main.qml"', '"Main.qml""Other.qml"'])
+def test_req_qml018_ac06_quote_concatenation_is_not_invented_as_two_paths(value):
+ """Unsupported qmake token concatenation rejects the required source fact."""
+ assert parse_qmake(BASE + f"QML_FILES += {value}\n")["qml_failures"]
+
+
+def test_req_qml018_ac06_scope_depth_is_bounded():
+ """Nested source scopes are refused before unbounded branch processing."""
+ result = parse_qmake("win32 {\n" * 65 + BASE + "}\n" * 65)
+ assert result["diagnostics"][0]["reason"] == "qmake_scope_limit"
+
+
+def test_req_qml018_ac01_message_text_does_not_mutate_named_metadata():
+ """Build messages mentioning source variables are coverage, not assignments."""
+ result = parse_qmake(BASE + 'message("QML_FILES = $$UNKNOWN")\n'
+ 'warning("include(other.pri)")\nQML_FILES += $$PWD/Main.qml\n')
+ assert not result.get("error")
+ assert [md["value"] for md in records(result, "qt_source")] == ["Main.qml"]
+
+
+def test_req_qml018_ac06_multibyte_path_transport_limit_is_explicit():
+ """UTF-8 transport bytes are bounded independently of character count."""
+ result = parse_qmake(BASE + "QML_FILES += $$PWD/" + "é" * 200 + ".qml\n")
+ assert result["diagnostics"][0]["reason"] == "qmake_path_limit"
diff --git a/tests/test_qt_qmake_adoption_integration.py b/tests/test_qt_qmake_adoption_integration.py
new file mode 100644
index 0000000000..cc71cbefaf
--- /dev/null
+++ b/tests/test_qt_qmake_adoption_integration.py
@@ -0,0 +1,193 @@
+"""REQ-QML-018-AC01/AC06: accepted qmake metadata through production consumers."""
+from __future__ import annotations
+
+import json
+import stat
+
+import pytest
+
+import graphify.extract as extraction
+from graphify.build import build_from_json
+from graphify.cache import cache_dir
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_qmake import extract_qmake
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_project_index import QtProjectIndex
+from graphify.watch import _rebuild_code
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+
+BUILD = """QT += qml quick
+CONFIG += c++17
+win32: LIBS += $$PWD/optional.lib
+QML_IMPORT_NAME = Public.Tools
+QML_IMPORT_VERSION = 1.0
+HEADERS += $$PWD/backend.hpp
+QML_FILES += $$PWD/Main.qml
+QML_IMPORT_PATH += $$PWD/imports/Public/Hidden
+QMLPATHS += $$PWD/imports
+"""
+HEADER = """class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service)
+ public: Q_INVOKABLE int fetch() { return 2; }
+};
+"""
+QML = "import Public.Tools 1.0\nService { property int result: fetch() }\n"
+PRODUCTS = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+
+
+def corpus(root):
+ """A nested public application owns membership; hint-only module stays separate."""
+ sources = {"app/tools.pro": BUILD, "app/backend.hpp": HEADER, "app/Main.qml": QML,
+ "app/imports/Public/Hidden/qmldir": "module Public.Hidden\nHidden 1.0 Hidden.qml\n",
+ "app/imports/Public/Hidden/Hidden.qml": "import QtQml\nQtObject {}\n",
+ "app/Consumer.qml": "import Public.Hidden 1.0\nHidden {}\n",
+ "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n"}
+ paths = []
+ for name, text in sources.items():
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(text.encode())
+ paths.append(path)
+ return paths
+
+
+def source_facts(graph, kind):
+ return {identity: qml_metadata(node) for identity, node in graph.nodes(data=True)
+ if qml_metadata(node).get("kind") == kind}
+
+
+def test_req_qml018_ac01_direct_facade_project_index_and_reload_preserve_pwd_roles(tmp_path):
+ """Membership reaches accepted endpoints; tooling/build hints supply no lookup root."""
+ paths = corpus(tmp_path)
+ direct = extract_qmake(tmp_path / "app/tools.pro", root=tmp_path)
+ result = extraction.extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not result["qml_failures"] and not result["failed_sources"]
+ qmake = {node["id"]: qml_metadata(node) for node in result["nodes"]
+ if node.get("source_file") == "app/tools.pro"}
+ direct_facts = {node["id"]: qml_metadata(node) for node in direct["nodes"]}
+ assert {identity: qmake[identity] for identity in direct_facts} == direct_facts
+ index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path)
+ assert index.module_context("app/backend.hpp").status == "resolved"
+ assert index.resolve_component("Public.Tools", "Main").status == "resolved"
+ assert index.resolve_component("Public.Hidden", "Hidden").status == "unavailable"
+ explicit = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path,
+ import_roots=("app/imports",))
+ assert explicit.resolve_component("Public.Hidden", "Hidden").status == "resolved"
+ native = [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")]
+ assert len([edge for edge in native if edge["relation"] == "calls"]) == 1
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "accepted.json"
+ assert to_json(graph, {}, str(output), force=True)
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert source_facts(graph, "qt_import_path") == source_facts(restored, "qt_import_path")
+ for md in source_facts(restored, "qt_import_path").values():
+ assert md["visibility"] in {"tooling", "build"}
+ assert md["path_origin"] == "current_file_pwd"
+ from graphify.serve import _query_graph_text
+ answer = _query_graph_text(restored, "app/Main.qml", token_budget=8000)
+ assert "app/Main.qml" in answer and "app/tools.pro" in answer and "references" in answer
+
+
+def test_req_qml018_ac01_build_pwd_paths_do_not_admit_disk_only_sources(tmp_path):
+ """A correct literal path cannot enlarge the explicitly accepted corpus."""
+ paths = corpus(tmp_path)
+ accepted = [path for path in paths if path.name != "backend.hpp"]
+ result = extraction.extract(accepted, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not result["qml_failures"]
+ assert not any(qt_metadata(edge).get("native_endpoint") for edge in result["edges"])
+ index = QtProjectIndex(result["nodes"], result["edges"], root=tmp_path)
+ assert index.module_context("app/backend.hpp").status == "unavailable"
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac01_metadata_only_pwd_edit_removal_matches_clean_updates(tmp_path, monkeypatch, operation):
+ """Only qmake bytes change; old membership and native edges retire faithfully."""
+ corpus(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold")
+ assert normalized(clean(tmp_path, tmp_path / ".cold")) == normalized(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert normalized(initial) == normalized(cold)
+ prior = unrelated(initial)
+ path = tmp_path / "app/tools.pro"
+ path.write_bytes(BUILD.replace("QML_IMPORT_VERSION = 1.0", "QML_IMPORT_VERSION = 2.0").encode())
+ edited = run(tmp_path, monkeypatch, operation, [path])
+ assert unrelated(edited) == prior
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited"))
+ assert not any(qt_metadata(data).get("native_endpoint") for _, _, data in edited.edges(data=True))
+ path.write_bytes(BUILD.replace("HEADERS += $$PWD/backend.hpp\n", "").encode())
+ removed = run(tmp_path, monkeypatch, operation, [path])
+ assert unrelated(removed) == prior
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed"))
+ assert not any(qt_metadata(data).get("native_endpoint") for _, _, data in removed.edges(data=True))
+ path.write_bytes(BUILD.encode())
+ recovered = run(tmp_path, monkeypatch, operation, [path])
+ assert normalized(recovered) == normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("mutation", [
+ "unix: QML_FILES += $$PWD/Hidden.qml\n", "include(unaccepted.pri)\n",
+ "QML_FILES += $$PWD/../../Outside.qml\n", "QMLPATHS += $$factory()\n",
+])
+def test_req_qml018_ac06_rejected_metadata_preserves_products_and_existing_cache(tmp_path, monkeypatch, capsys, operation, mutation):
+ """Actual metadata rejection leaves accepted disk products and cache entries intact."""
+ corpus(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ prior = {name: (output / name).read_bytes() for name in PRODUCTS}
+ cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*") if path.is_file()}
+ assert cache
+ path = tmp_path / "app/tools.pro"
+ path.write_bytes((BUILD + mutation).encode())
+ if operation == "manual":
+ with pytest.raises(SystemExit) as failure:
+ run(tmp_path, monkeypatch, operation, [path])
+ assert failure.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True)
+ assert prior == {name: (output / name).read_bytes() for name in PRODUCTS}
+ assert all(path.exists() and path.read_bytes() == data for path, data in cache.items())
+ assert "QML_GRAPH_PRESERVED" in capsys.readouterr().out
+ assert extract_qmake(path, root=tmp_path)["diagnostics"][0]["code"] == "QML_PROJECT_UNSUPPORTED"
+ path.write_bytes(BUILD.encode())
+ repaired = run(tmp_path, monkeypatch, operation, [path])
+ assert normalized(repaired) == normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac06_readonly_publication_after_metadata_edit_retains_and_retries(tmp_path, monkeypatch, operation):
+ """A real read-only graph destination rejects changed metadata publication."""
+ import os
+ if os.name != "nt":
+ pytest.skip("Actual Windows read-only destination attribute")
+ corpus(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ prior = {name: (output / name).read_bytes() for name in PRODUCTS}
+ path = tmp_path / "app/tools.pro"
+ path.write_bytes(BUILD.replace("QML_IMPORT_VERSION = 1.0", "QML_IMPORT_VERSION = 2.0").encode())
+ target = output / "graph.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ if operation == "manual":
+ with pytest.raises(SystemExit) as failure:
+ run(tmp_path, monkeypatch, operation, [path])
+ assert failure.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True)
+ assert prior == {name: (output / name).read_bytes() for name in PRODUCTS}
+ finally:
+ target.chmod(stat.S_IWRITE)
+ repaired = run(tmp_path, monkeypatch, operation, [path])
+ assert normalized(repaired) != normalized(initial)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".readonly-retry"))
diff --git a/tests/test_qt_qml_bridge.py b/tests/test_qt_qml_bridge.py
new file mode 100644
index 0000000000..7453a87e59
--- /dev/null
+++ b/tests/test_qt_qml_bridge.py
@@ -0,0 +1,161 @@
+"""Native QML providers require explicit source/module/member provenance."""
+from __future__ import annotations
+
+import copy
+import json
+
+from graphify.build import build_from_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_qml_bridge import build_qt_qml_bridge
+from graphify.export import to_json
+from tests.qt_cpp_test_helpers import HEADER, collect, facts
+
+
+def native_corpus(tmp_path, calls=None, header=HEADER):
+ calls = calls or 'void install(){ qmlRegisterType("Demo",1,0,"Backend"); }'
+ return collect(tmp_path, {"backend.hpp": header, "register.cpp": calls})
+
+
+def test_literal_provider_members_and_exact_endpoint_proof(tmp_path):
+ result = native_corpus(tmp_path)
+ before = copy.deepcopy(result)
+ index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path)
+ provider = index.module_type("Demo", 1, 0, "Backend", importer="Main.qml")
+ assert provider.status == "resolved" and index.is_provider(provider.target_id)
+ assert index.module_import("Demo", 1, 0).status == "resolved"
+ assert index.metadata(provider.target_id)["kind"] == "component"
+ for name, kind in (("next", "function"), ("valueChanged", "signal"), ("value", "property"), ("setValue", "function")):
+ member = index.member(provider.target_id, name)
+ assert member.status == "resolved"
+ target = next(node for node in result["nodes"] if node["id"] == member.target_id)
+ if kind != "property":
+ assert target.get("_callable") is True
+ assert not qt_metadata(target)
+ assert index.metadata(member.target_id)["kind"] == kind
+ proof = index.endpoint_proof(member.target_id, member.evidence)
+ assert proof["canonical_target_id"] == member.target_id
+ assert proof["class_id"] == qt_metadata(facts(result, "class", "Backend")[0])["class_id"]
+ assert proof["member_fact_id"] in member.evidence
+ assert index.endpoint_proof(member.target_id, ()) == {}
+ assert index.metadata(index.member(provider.target_id, "valueChanged").target_id)["parameter_names"] == ["value"]
+ assert index.member(provider.target_id, "ordinary").status == "unavailable"
+ assert index.member(provider.target_id, "hidden").status == "unavailable"
+ assert result == before
+
+
+def test_module_namespace_multiple_types_not_ambiguous_duplicate_export_is(tmp_path):
+ calls = '''void install(){
+qmlRegisterType("Demo",1,0,"Backend");
+qmlRegisterType("Demo",1,0,"Another");
+qmlRegisterType("Demo",1,0,"Duplicate");
+qmlRegisterType("Demo",1,0,"Duplicate");
+}'''
+ result = native_corpus(tmp_path, calls)
+ index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path)
+ imported = index.module_import("Demo", 1, 0)
+ assert imported.status == "resolved" and len(imported.evidence) == 4
+ duplicate = index.module_type("Demo", 1, 0, "Duplicate", importer="Main.qml")
+ assert duplicate.status == "ambiguous" and len(duplicate.candidates) == 2
+ for name in ("Backend", "Another"):
+ assert index.module_type("Demo", 1, 0, name).status == "resolved"
+ assert index.endpoint_proof(imported.target_id, imported.evidence, kind="module")["kind"] == "module"
+
+
+def test_overloads_ambiguous_before_generic_method_ids_collapse(tmp_path):
+ header = HEADER.replace("Q_INVOKABLE int next(int amount);", "Q_INVOKABLE int next(int amount);\n Q_INVOKABLE int next(QString value);")
+ result = native_corpus(tmp_path, header=header)
+ declarations = [qt_metadata(node) for node in facts(result, "member", "next")]
+ assert {md["signature"] for md in declarations} == {"next(int)", "next(QString)"}
+ assert len({md["generic_target_id"] for md in declarations}) == 1
+ index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path)
+ provider = index.module_type("Demo", 1, 0, "Backend")
+ answer = index.member(provider.target_id, "next")
+ assert answer.status == "ambiguous" and answer.target_id is None
+ assert answer.reason == "native_member_or_overload_ambiguous"
+
+
+def test_versions_namespaces_singleton_anonymous_and_no_label_fallback(tmp_path):
+ calls = '''void install(){
+qmlRegisterType("First",1,0,"Backend");
+qmlRegisterSingletonType("Second",2,3,"State",factory);
+qmlRegisterAnonymousType("Second",2);
+}'''
+ result = native_corpus(tmp_path, calls)
+ index = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path)
+ assert index.module_type("Second", 2, 3, "Backend").status == "unavailable"
+ assert index.module_type("First", 2, 0, "Backend").status == "unavailable"
+ assert index.module_type("First", 1, 1, "Backend").reason == "native_module_version_unavailable"
+ state = index.module_type("Second", 2, 3, "State")
+ assert index.metadata(state.target_id)["singleton"]
+ assert index.module_type("Second", 2, 0, "").status == "unavailable"
+ assert index.module_type("Missing", None, None, "Backend").status == "unavailable"
+
+
+def test_macro_provider_requires_real_source_membership_build_context(tmp_path):
+ from graphify.extractors.qml_cmake import extract_cmake
+ from graphify.qt_project_index import QtProjectIndex
+ result = collect(tmp_path, {"backend.hpp": HEADER})
+ no_context = build_qt_qml_bridge(result["nodes"], result["edges"], root=tmp_path)
+ assert not no_context.provider_records
+ build_file = tmp_path / "CMakeLists.txt"
+ build_file.write_text('qt_add_qml_module(app URI Demo VERSION 1.2 SOURCES backend.hpp)\n')
+ metadata = extract_cmake(build_file, root=tmp_path)
+ nodes, edges = result["nodes"] + metadata["nodes"], result["edges"] + metadata["edges"]
+ project = QtProjectIndex(nodes, edges, root=tmp_path)
+ index = build_qt_qml_bridge(nodes, edges, root=tmp_path, project_index=project)
+ provider = index.module_type("Demo", 1, 2, "Backend")
+ assert provider.status == "resolved" and project.module_context("backend.hpp").target_id in provider.evidence
+ assert index.module_type("Demo", 1, 0, "Backend").status == "resolved"
+ assert index.module_type("Demo", 1, 1, "Backend").status == "resolved"
+ assert index.module_type("Demo", 1, 3, "Backend").status == "unavailable"
+ assert index.module_type("Other", 1, 2, "Backend").status == "unavailable"
+
+
+def test_qmake_major_only_macro_registration_uses_documented_base_minor_zero(tmp_path):
+ from graphify.extractors.qml_qmake import extract_qmake
+ from graphify.qt_project_index import QtProjectIndex
+ result = collect(tmp_path, {"backend.hpp": HEADER})
+ project_file = tmp_path / "app.pro"
+ project_file.write_text('CONFIG += qmltypes\nQML_IMPORT_NAME = Demo\nQML_IMPORT_MAJOR_VERSION = 1\nHEADERS += backend.hpp\n')
+ metadata = extract_qmake(project_file, root=tmp_path)
+ nodes, edges = result["nodes"] + metadata["nodes"], result["edges"] + metadata["edges"]
+ index = build_qt_qml_bridge(nodes, edges, root=tmp_path,
+ project_index=QtProjectIndex(nodes, edges, root=tmp_path))
+ assert index.module_type("Demo", 1, 0, "Backend").status == "resolved"
+ assert index.module_type("Demo", 1, 1, "Backend").status == "unavailable"
+
+
+def test_macro_introduction_and_removal_do_not_equal_module_maximum(tmp_path):
+ from graphify.extractors.qml_cmake import extract_cmake
+ from graphify.qt_project_index import QtProjectIndex
+ header = HEADER.replace("QML_NAMED_ELEMENT(Backend)", "QML_NAMED_ELEMENT(Backend) QML_ADDED_IN_VERSION(1,1) QML_REMOVED_IN_VERSION(1,2)")
+ result = collect(tmp_path, {"backend.hpp": header})
+ project_file = tmp_path / "CMakeLists.txt"
+ project_file.write_text('qt_add_qml_module(app URI Demo VERSION 1.2 SOURCES backend.hpp)\n')
+ metadata = extract_cmake(project_file, root=tmp_path)
+ nodes, edges = result["nodes"] + metadata["nodes"], result["edges"] + metadata["edges"]
+ index = build_qt_qml_bridge(nodes, edges, root=tmp_path,
+ project_index=QtProjectIndex(nodes, edges, root=tmp_path))
+ assert index.module_type("Demo", 1, 0, "Backend").status == "unavailable"
+ assert index.module_type("Demo", 1, 1, "Backend").status == "resolved"
+ assert index.module_type("Demo", 1, 2, "Backend").status == "unavailable"
+
+
+def test_source_facts_build_and_json_reload_keep_members_spans_and_direction(tmp_path):
+ result = native_corpus(tmp_path)
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "roundtrip.json"
+ assert to_json(graph, {}, str(output))
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ expected = {node["id"] for node in result["nodes"] if qt_metadata(node)}
+ assert expected.issubset(restored.nodes)
+ for nid in expected:
+ assert qt_metadata(dict(restored.nodes[nid]))["span"] == qt_metadata(next(node for node in result["nodes"] if node["id"] == nid))["span"]
+ for edge in result["edges"]:
+ if edge.get("context") != "qt_property_accessor":
+ continue
+ assert restored.has_edge(edge["source"], edge["target"])
+ data = restored.edges[edge["source"], edge["target"]]
+ assert data["confidence"] == "EXTRACTED" and data["context"] == "qt_property_accessor"
+ assert (data["_src"], data["_tgt"]) == (edge["source"], edge["target"])
diff --git a/tests/test_qt_qml_export_consumers.py b/tests/test_qt_qml_export_consumers.py
new file mode 100644
index 0000000000..e0d7841088
--- /dev/null
+++ b/tests/test_qt_qml_export_consumers.py
@@ -0,0 +1,88 @@
+"""Actual native bridge evidence survives supported machine-readable exports."""
+from __future__ import annotations
+
+import json
+
+import networkx as nx
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_graphml, to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.serve import _shortest_path_text
+from tests.test_qt_qml_integration import corpus
+
+
+def bridge_edges(result):
+ return [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")]
+
+
+def test_directed_graphml_roundtrip_keeps_native_evidence_and_literal_source_fields(tmp_path):
+ result = corpus(tmp_path)
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ output = tmp_path / "native.graphml"
+ to_graphml(graph, {}, str(output))
+ restored = nx.read_graphml(output)
+ assert restored.is_directed()
+ assert set(restored) == set(graph)
+ assert set(restored.edges) == set(graph.edges)
+ for node_id, original in graph.nodes(data=True):
+ if original.get("metadata"):
+ data = dict(restored.nodes[node_id])
+ data["metadata"] = json.loads(data["metadata"])
+ assert qml_metadata(data) == qml_metadata(original)
+ assert qt_metadata(data) == qt_metadata(original)
+ assert data["source_file"] == original["source_file"]
+ assert data["source_location"] == original["source_location"]
+ for edge in bridge_edges(result):
+ data = dict(restored.edges[edge["source"], edge["target"]])
+ data["metadata"] = json.loads(data["metadata"])
+ assert qt_metadata(data)["native_endpoint"] == qt_metadata(edge)["native_endpoint"]
+ assert (data["relation"], data["context"], data["confidence"]) == (
+ edge["relation"], edge["context"], "INFERRED")
+ assert (data["source_file"], data["source_location"]) == (edge["source_file"], edge["source_location"])
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_json_repeated_native_mechanisms_remain_distinct_and_directional(tmp_path, directed):
+ qml = '''import Demo 1.0 as Native
+Native.Backend {
+ property int first: next(1)
+ property int second: next(2)
+ property int third: value
+ property int fourth: value
+ onValueChanged: { next(value); }
+}'''
+ result = corpus(tmp_path, qml=qml)
+ expected = bridge_edges(result)
+ calls = [edge for edge in expected if edge["relation"] == "calls"]
+ assert len(calls) == 3
+ assert len({edge["source"] for edge in calls}) == 3
+ assert len({edge["target"] for edge in calls}) == 1
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ output = tmp_path / "native.json"
+ assert to_json(graph, {}, str(output), force=True)
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ for edge in expected:
+ data = restored.edges[edge["source"], edge["target"]]
+ assert (data.get("_src", edge["source"]), data.get("_tgt", edge["target"])) == (edge["source"], edge["target"])
+ assert qt_metadata(data)["native_endpoint"] == qt_metadata(edge)["native_endpoint"]
+ assert data["relation"] == edge["relation"] and data["confidence"] == "INFERRED"
+ assert data["source_file"] == "Main.qml" and data["source_location"].startswith("L")
+
+
+def test_native_path_reports_qml_call_site_and_cpp_declaration_without_reversing_flow(tmp_path):
+ result = corpus(tmp_path)
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ call = next(edge for edge in bridge_edges(result) if edge["relation"] == "calls")
+ route = _shortest_path_text(graph, {"source": call["source"], "target": call["target"]})
+ assert "Shortest path (1 hops)" in route and "--calls [INFERRED]-->" in route
+ assert f'Main.qml:{call["source_location"]}' in route
+ target = graph.nodes[call["target"]]
+ assert f'backend.hpp:{target["source_location"]}' in route
+ assert "qml_js_call" in route
+ assert "native_endpoint" not in route and "raw_values" not in route
+ reverse = _shortest_path_text(graph, {"source": call["target"], "target": call["source"]})
+ assert "No directed path found" in reverse
diff --git a/tests/test_qt_qml_integration.py b/tests/test_qt_qml_integration.py
new file mode 100644
index 0000000000..0943d9cdbe
--- /dev/null
+++ b/tests/test_qt_qml_integration.py
@@ -0,0 +1,119 @@
+"""Actual facade-to-graph native Qt/QML bridges and scoped rejection cases."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import extract
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.qt_cpp_test_helpers import HEADER, write
+
+
+SOURCE = '''import Demo 1.0 as Native
+Native.Backend {
+ id: root
+ property int doubled: value * 2
+ property int called: next(1)
+ onValueChanged: { doubled = value; }
+}
+'''
+
+
+def corpus(root, qml=SOURCE, header=HEADER, calls=None):
+ calls = calls or 'void install(){ qmlRegisterType("Demo",1,0,"Backend"); }'
+ paths = [write(root, "backend.hpp", header), write(root, "register.cpp", calls), write(root, "Main.qml", qml)]
+ return extract(paths, root=root, cache_root=root / "cache", parallel=False)
+
+
+def sites(result, kind):
+ return [node for node in result["nodes"] if qml_metadata(node).get("kind") == kind]
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_actual_native_import_property_call_and_subscription_survive_roundtrip(tmp_path, directed):
+ result = corpus(tmp_path)
+ assert not result.get("qml_failures")
+ assert all(qml_metadata(node)["status"] == "resolved" for kind in ("import_resolution", "type_use", "handler", "call")
+ for node in sites(result, kind))
+ reads = sites(result, "read")
+ assert len(reads) == 2
+ assert {qml_metadata(node)["status"] for node in reads} == {"resolved", "dynamic"}
+ shadow = next(qml_metadata(node) for node in reads if qml_metadata(node)["status"] == "dynamic")
+ assert shadow["reference"] == "value" and shadow["reason"] == "javascript_lexical_binding"
+ edges = [edge for edge in result["edges"] if qt_metadata(edge).get("native_endpoint")]
+ assert {(edge["relation"], edge["context"]) for edge in edges} == {
+ ("imports", "qml_import_resolution"), ("uses", "qml_type_resolution"),
+ ("uses", "qml_binding_read"), ("calls", "qml_js_call"), ("references", "qml_signal_subscription")}
+ for edge in edges:
+ assert edge["confidence"] == "INFERRED"
+ proof = qt_metadata(edge)["native_endpoint"]
+ assert proof["canonical_target_id"] == edge["target"]
+ assert proof["provider_id"] in proof["evidence"]
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ output = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(output))
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert restored.is_directed() == directed
+ for edge in edges:
+ assert graph.has_edge(edge["source"], edge["target"])
+ assert restored.has_edge(edge["source"], edge["target"])
+ data = restored.edges[edge["source"], edge["target"]]
+ assert (data["relation"], data["context"], data["confidence"]) == (edge["relation"], edge["context"], "INFERRED")
+ assert (data.get("_src", edge["source"]), data.get("_tgt", edge["target"])) == (edge["source"], edge["target"])
+ assert qt_metadata(data)["native_endpoint"] == qt_metadata(edge)["native_endpoint"]
+
+
+def test_duplicate_registration_has_no_arbitrary_type_or_member_bridge(tmp_path):
+ calls = 'void install(){ qmlRegisterType("Demo",1,0,"Backend"); qmlRegisterType("Demo",1,0,"Backend"); }'
+ result = corpus(tmp_path, calls=calls)
+ assert qml_metadata(sites(result, "type_use")[0])["status"] == "ambiguous"
+ assert all(qml_metadata(node)["status"] != "resolved" for kind in ("call", "handler") for node in sites(result, kind))
+ assert not any(qt_metadata(edge).get("native_endpoint", {}).get("member_fact_id") for edge in result["edges"])
+
+
+def test_ambiguous_overload_and_version_revised_member_are_explicit(tmp_path):
+ header = HEADER.replace("Q_INVOKABLE int next(int amount);", "Q_INVOKABLE int next(int amount); Q_INVOKABLE int next(QString value);")
+ result = corpus(tmp_path, header=header)
+ assert qml_metadata(sites(result, "call")[0])["status"] == "ambiguous"
+ header = HEADER.replace("Q_INVOKABLE int next(int amount);", "Q_REVISION(1, 2) Q_INVOKABLE int next(int amount);")
+ result = corpus(tmp_path, header=header)
+ call = qml_metadata(sites(result, "call")[0])
+ assert call["status"] == "unsupported" and call["reason"] == "native_member_revision_requires_supported_version"
+ header = HEADER.replace("NOTIFY valueChanged)", "NOTIFY valueChanged REVISION 2)")
+ result = corpus(tmp_path, header=header)
+ assert any(qml_metadata(node)["status"] == "unsupported" for node in sites(result, "read"))
+
+
+@pytest.mark.parametrize("registration", [
+ 'qmlRegisterUncreatableType("Demo",1,0,"Backend","reason")',
+ 'qmlRegisterSingletonType("Demo",1,0,"Backend",factory)',
+])
+def test_noncreatable_native_object_declaration_does_not_get_type_edge(tmp_path, registration):
+ result = corpus(tmp_path, calls=f"void install(){{ {registration}; }}")
+ site = sites(result, "type_use")[0]
+ assert qml_metadata(site)["status"] == "unsupported" and qml_metadata(site)["reason"] == "native_type_not_creatable"
+ assert not any(edge["source"] == site["id"] and edge["relation"] == "uses" for edge in result["edges"])
+
+
+def test_registered_singleton_qualifier_member_uses_exact_source_type(tmp_path):
+ qml = 'import Demo 1.0 as Native\nQtObject { property int result: Native.State.next(1) }'
+ calls = 'void install(){ qmlRegisterSingletonType("Demo",1,0,"State",factory); }'
+ result = corpus(tmp_path, qml=qml, calls=calls)
+ call = sites(result, "call")[0]
+ assert qml_metadata(call)["status"] == "resolved"
+ target = qml_metadata(call)["resolved_target_id"]
+ assert next(node for node in result["nodes"] if node["id"] == target)["_callable"]
+ assert any(edge["source"] == call["id"] and edge["target"] == target and qt_metadata(edge).get("native_endpoint") for edge in result["edges"])
+
+
+def test_native_property_does_not_invent_automatic_qml_notify_signal(tmp_path):
+ header = HEADER.replace("NOTIFY valueChanged", "").replace("void valueChanged(int value);", "")
+ result = corpus(tmp_path, header=header)
+ handler = qml_metadata(sites(result, "handler")[0])
+ assert handler["status"] != "resolved"
+ assert sites(result, "property_change_signal") == []
diff --git a/tests/test_qt_qml_projection.py b/tests/test_qt_qml_projection.py
new file mode 100644
index 0000000000..4e1954b4e4
--- /dev/null
+++ b/tests/test_qt_qml_projection.py
@@ -0,0 +1,60 @@
+"""Typed bridge evidence survives the production builder and fails closed."""
+import copy
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.extract import extract
+from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.export import to_json
+
+
+def bridge(tmp_path):
+ cpp = tmp_path / "backend.cpp"
+ cpp.write_text('''class Backend : public QObject {
+ Q_OBJECT
+ public: Q_INVOKABLE int refresh() { return 1; }
+ signals: void ready(int value);
+ };
+ void registerTypes() { qmlRegisterType("Demo.Tools", 1, 0, "Service"); }
+ ''', encoding="utf-8")
+ qml = tmp_path / "Main.qml"
+ qml.write_text('import Demo.Tools 1.0\nService { property int count: refresh(); onReady: count = 1 }', encoding="utf-8")
+ result = extract([cpp, qml], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not result["qml_failures"] and not result["failed_sources"]
+ return result
+
+
+def test_native_import_call_and_subscription_survive_default_graph_reload(tmp_path):
+ result = bridge(tmp_path)
+ graph = build_from_json(result, root=tmp_path)
+ calls = [edge for edge in result["edges"] if edge.get("relation") == "calls" and qt_metadata(edge).get("native_endpoint")]
+ assert len(calls) == 1
+ endpoint = calls[0]
+ assert graph.has_edge(endpoint["source"], endpoint["target"])
+ assert graph.edges[endpoint["source"], endpoint["target"]]["relation"] == "calls"
+ exported = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(exported))
+ loaded = load_node_link_graph(exported)
+ assert loaded.edges[endpoint["source"], endpoint["target"]]["_src"] == endpoint["source"]
+ assert loaded.edges[endpoint["source"], endpoint["target"]]["_tgt"] == endpoint["target"]
+ contexts = {data.get("context") for _, _, data in loaded.edges(data=True)}
+ assert {"qml_import_resolution", "qml_type_resolution", "qml_signal_subscription"} <= contexts
+
+
+@pytest.mark.parametrize("damage", ["missing", "target", "class", "member", "provider"])
+def test_bridge_context_and_label_cannot_replace_exact_endpoint_proof(tmp_path, damage):
+ result = bridge(tmp_path)
+ edge = next(edge for edge in result["edges"] if edge.get("relation") == "calls" and qt_metadata(edge).get("native_endpoint"))
+ source, target = edge["source"], edge["target"]
+ values = qt_metadata(edge)
+ proof = copy.deepcopy(values["native_endpoint"])
+ if damage == "missing":
+ edge["metadata"].pop("qt")
+ else:
+ field = {"target": "canonical_target_id", "class": "class_id", "member": "member_fact_id", "provider": "provider_id"}[damage]
+ proof[field] = source
+ edge["metadata"]["qt"] = encode_qt({**values, "native_endpoint": proof})
+ graph = build_from_json(result, root=tmp_path)
+ assert not graph.has_edge(source, target)
diff --git a/tests/test_qt_qml_search.py b/tests/test_qt_qml_search.py
new file mode 100644
index 0000000000..1b70e6b580
--- /dev/null
+++ b/tests/test_qt_qml_search.py
@@ -0,0 +1,120 @@
+"""Semantic metadata participates in production scoring without identity leakage."""
+from __future__ import annotations
+
+import copy
+
+import networkx as nx
+import pytest
+
+from graphify.extractors.qml_facts import encode_metadata
+from graphify.extractors.qt_cpp_facts import encode_qt
+from graphify.qt_qml_search import SEARCH_NAMESPACE, search_attributes
+from graphify.serve import _node_attributes_text, _pick_scored_endpoint, _query_graph_text, _score_nodes
+
+
+def node():
+ return {"id": "opaque_node", "label": "declaration", "attributes": {"user": {"note": "kept"}},
+ "metadata": {"qt": encode_qt({"kind": "member", "raw_name": "fetch", "raw_type": "QList",
+ "class_name": "Public::Backend", "signature": "fetch(int)",
+ "roles": ["invokable"], "uri": "Public.Tools", "status": "resolved",
+ "owner_id": "hidden_owner_93", "generic_target_id": "hidden_target_43"})}}
+
+
+def test_decoded_names_types_roles_and_uris_are_indexed_without_mutating_facts():
+ data = node()
+ before = copy.deepcopy(data)
+ attrs = search_attributes(data)
+ assert attrs["user"] == before["attributes"]["user"]
+ assert attrs[SEARCH_NAMESPACE]["qt"]["raw_type"] == "QList"
+ norm, tokens = _node_attributes_text(data)
+ assert all(value in norm for value in ("invokable", "public::backend", "fetch(int)", "public.tools", "qlist"))
+ assert "invokable" in tokens and "hidden_owner_93" not in norm and "hidden_target_43" not in norm
+ assert "raw_values" not in norm
+ assert data == before
+ graph = nx.Graph()
+ graph.add_node("native", **data)
+ graph.add_node("unrelated", label="other")
+ assert _score_nodes(graph, ["invokable"])[0][1] == "native"
+
+
+def test_qml_import_value_is_semantic_but_arbitrary_literal_property_data_is_not():
+ imported = {"metadata": {"qml": encode_metadata({"kind": "import", "value": "Public.Tools", "qualifier": "Tools"})}}
+ assert search_attributes(imported)[SEARCH_NAMESPACE]["qml"]["import_value"] == "Public.Tools"
+ prop = {"metadata": {"qml": encode_metadata({"kind": "property", "raw_name": "value", "literal_value": "private_text", "value": "private_text"})}}
+ assert "private_text" not in str(search_attributes(prop))
+
+
+@pytest.mark.parametrize("metadata", [None, "invalid", {"qt": []}, {"qt": {"contract_version": 2, "raw_name": "future"}},
+ {"qt": {"contract_version": 1, "raw_values": {"raw_name": "!!!"}}}])
+def test_missing_future_or_corrupted_metadata_preserves_existing_search(metadata):
+ data = {"attributes": {"user": "existing"}, "metadata": metadata}
+ assert search_attributes(data) == {"user": "existing"}
+ assert "existing" in _node_attributes_text(data)[0]
+
+
+def test_reserved_attribute_collision_wins_without_overwrite():
+ data = node()
+ data["attributes"][SEARCH_NAMESPACE] = {"custom": "kept"}
+ before = copy.deepcopy(data)
+ assert search_attributes(data) == data["attributes"]
+ assert data == before
+
+
+def test_metadata_text_and_list_work_are_bounded():
+ data = {"metadata": {"qt": {"contract_version": 1, "kind": "member", "raw_name": "x" * 10_000,
+ "roles": ["x"] * 10_000, "uri": "Public.Tools"}}}
+ view = search_attributes(data)[SEARCH_NAMESPACE]["qt"]
+ assert "raw_name" not in view and "roles" not in view and view["uri"] == "Public.Tools"
+ assert len(str(view)) < 100
+
+
+@pytest.mark.parametrize("name", ["qt", "qml"])
+def test_deep_malformed_literal_transport_cannot_crash_production_search(name):
+ nested = "not encoded text"
+ for _ in range(3000):
+ nested = [nested]
+ data = {"label": "safe", "attributes": {"user": "existing searchable text"},
+ "metadata": {name: {"contract_version": 1, "kind": "member", "raw_name": "safe",
+ "raw_values": {"raw_name": nested}}}}
+ assert search_attributes(data) == data["attributes"]
+ assert "existing searchable text" in _node_attributes_text(data)[0]
+ graph = nx.Graph()
+ graph.add_node("safe", **data)
+ assert "NODE safe" in _query_graph_text(graph, "existing", depth=1)
+ assert data["metadata"][name]["raw_values"]["raw_name"] is nested
+
+
+def test_public_event_semantics_are_searchable_without_indexing_supplied_data():
+ data = {"metadata": {"qt": encode_qt({"kind": "connect", "declared_type": "QueuedConnection",
+ "flags": ["UniqueConnection"], "callable_form": "member_pointer", "operation": "connect",
+ "bridge_direction": "cpp_to_qml", "literal_value": "unrelated_private_literal"})}}
+ text = _node_attributes_text(data)[0]
+ assert all(value in text for value in ("queuedconnection", "uniqueconnection", "member_pointer", "cpp_to_qml"))
+ assert "unrelated_private_literal" not in text
+
+
+def test_exact_endpoint_identity_wins_over_fuzzy_token_collision():
+ graph = nx.Graph()
+ graph.add_node("qml:Main.qml:component:aaa", label="Main")
+ graph.add_node("qml:Main.qml:property:bbb", label="input")
+ scored = [(1000.0, "qml:Main.qml:component:aaa"), (1.0, "qml:Main.qml:property:bbb")]
+ assert _pick_scored_endpoint(graph, scored, "qml:Main.qml:property:bbb") == "qml:Main.qml:property:bbb"
+
+
+def test_fuzzy_endpoint_still_prefers_full_token_coverage():
+ graph = nx.Graph()
+ graph.add_node("summary", label="Rejection Summary")
+ graph.add_node("judge", label="Degenerate Reject-Everything Judge")
+ assert _pick_scored_endpoint(graph, [(1000, "summary"), (1, "judge")], "Reject-everything judge") == "judge"
+
+
+def test_query_edge_rendering_is_identical_for_reversed_actual_graph_insertion(tmp_path):
+ from graphify.build import build_from_json
+ from tests.test_qt_qml_integration import corpus
+ original = build_from_json(corpus(tmp_path), root=tmp_path)
+ reversed_graph = nx.Graph()
+ reversed_graph.add_nodes_from(reversed(list(original.nodes(data=True))))
+ reversed_graph.add_edges_from(reversed(list(original.edges(data=True))))
+ query = _query_graph_text(original, "next", depth=2)
+ assert query == _query_graph_text(reversed_graph, "next", depth=2)
+ assert "EDGE" in query and "qml_js_call" in query and "Main.qml:L" in query
diff --git a/tests/test_qt_query_consumers.py b/tests/test_qt_query_consumers.py
new file mode 100644
index 0000000000..ef85e4a22b
--- /dev/null
+++ b/tests/test_qt_query_consumers.py
@@ -0,0 +1,32 @@
+"""QML-013-AC01 exact scope and metadata through actual query/explain/path CLI."""
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.serve import _load_graph, _node_attributes_text, _shortest_path_text
+from graphify.qt_qml_search import search_attributes
+from tests.qt_analysis_helpers import analysis, sites
+from tests.qt_consumer_helpers import cli, saved_graph
+from tests.test_qt_signals_slots import SOURCE
+
+
+def test_cli_query_explain_and_path_use_source_scoped_nodes(tmp_path, monkeypatch, capsys):
+ path, result, component, prop, read = saved_graph(tmp_path)
+ query = cli(monkeypatch, capsys, path, "query", read["id"])
+ assert "Main.qml" in query and "INFERRED" in query
+ explanation = cli(monkeypatch, capsys, path, "explain", prop["id"])
+ assert "Main.qml" in explanation and prop["id"] in explanation
+ route = cli(monkeypatch, capsys, path, "path", component["id"], prop["id"])
+ assert "No path" not in route and "input" in route
+ loaded = _load_graph(str(path))
+ text = _shortest_path_text(loaded, {"source": component["id"], "target": prop["id"]})
+ assert "input" in text and "Main.qml" in text
+ other_ids = {node["id"] for node in result["nodes"] if node["source_file"] == "Other.qml"}
+ assert not other_ids & {target for _, target in loaded.out_edges(read["id"])}
+
+
+def test_public_connection_type_is_searchable_without_opaque_transport(tmp_path):
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ connection = sites(result, "connect")[0]
+ assert qt_metadata(connection)["declared_type"] == "QueuedConnection"
+ text, _ = _node_attributes_text(connection)
+ assert "queuedconnection" in text
+ attributes = search_attributes(connection)
+ assert "raw_values" not in str(attributes) and "owner_scope_key" not in str(attributes)
diff --git a/tests/test_qt_readonly_publication.py b/tests/test_qt_readonly_publication.py
new file mode 100644
index 0000000000..2c41753922
--- /dev/null
+++ b/tests/test_qt_readonly_publication.py
@@ -0,0 +1,74 @@
+"""REQ-QML-018-AC06: real Windows rejection through manual/watch publication."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import stat
+
+import pytest
+
+import graphify.watch as watch
+from graphify.paths import write_text_atomic
+from tests.test_qt_final_incremental_parity import clean, normalized, project, run
+
+
+pytestmark = pytest.mark.skipif(os.name != "nt", reason="Actual Windows read-only attribute")
+
+
+def durable(root):
+ """Only accepted products/cache entries count; candidate scratch is not accepted."""
+ output = root / "graphify-out"
+ files = [output / name for name in
+ ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")]
+ files.extend(path for path in (output / "cache").rglob("*") if path.is_file())
+ return {path.relative_to(output).as_posix(): path.read_bytes() for path in files}
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac06_readonly_graph_preserves_products_then_retries(tmp_path, monkeypatch, operation):
+ """The real OS rejects the candidate after successful source analysis; retry agrees."""
+ source = project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ accepted = durable(tmp_path)
+ source.write_text(source.read_text(encoding="utf-8").replace("adjustment", "offset"),
+ encoding="utf-8", newline="")
+ target = tmp_path / "graphify-out/graph.json"
+ target.chmod(stat.S_IREAD)
+ try:
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, monkeypatch, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert durable(tmp_path) == accepted
+ assert not target.stat().st_mode & stat.S_IWRITE
+ assert not list(target.parent.glob(".gfy-*.tmp"))
+ finally:
+ target.chmod(stat.S_IWRITE)
+ repaired = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(repaired) != normalized(initial)
+ assert normalized(repaired) == normalized(clean(tmp_path, tmp_path / ".repaired"))
+ current = durable(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(repaired)
+ assert durable(tmp_path) == current
+
+
+@pytest.mark.parametrize("name", ["manifest.json", ".qt_analysis.json", ".graphify_root", "cache/entry.json"])
+def test_req_qml018_ac06_individual_readonly_product_writer_preserves_prior(tmp_path, name):
+ """Every product's shared writer rejects without changing that accepted destination."""
+ target = tmp_path / "graphify-out" / name
+ target.parent.mkdir(parents=True)
+ target.write_bytes(b"accepted\r\n")
+ target.chmod(stat.S_IREAD)
+ try:
+ with pytest.raises(PermissionError):
+ write_text_atomic(target, "candidate\n")
+ assert target.read_bytes() == b"accepted\r\n"
+ assert {path for path in target.parent.iterdir()} == {target}
+ finally:
+ target.chmod(stat.S_IWRITE)
+ write_text_atomic(target, "candidate\n")
+ assert target.read_text(encoding="utf-8") == "candidate\n"
diff --git a/tests/test_qt_receiver_boundaries.py b/tests/test_qt_receiver_boundaries.py
new file mode 100644
index 0000000000..9498873ea7
--- /dev/null
+++ b/tests/test_qt_receiver_boundaries.py
@@ -0,0 +1,224 @@
+"""REQ-QML-017-AC02/AC04: persisted reflection belongs to its receiving QObject."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import encode_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qml_resolution_types import qml_metadata
+from graphify.qt_qml_access_index import QtQmlAccessIndex
+from tests.qt_analysis_helpers import analysis, sites
+
+QML = '''import QtQuick
+Item {
+ id: owner
+ objectName: "owner"
+ property int rootOnly: 7
+ function rootAction() {}
+ Item { id: left; objectName: "left"; property int localValue: 1;
+ Item { objectName: "deep"; property int count: 2 } }
+ Item { id: right; objectName: "right"; property int localValue: 2 }
+}
+'''
+TARGET_CONTEXTS = {"qt_cpp_qml_property_read", "qt_cpp_qml_property_write", "qt_cpp_qml_invoke", "qt_cpp_qml_find_child"}
+
+
+def source(root, tail):
+ return f'''// original-byte Unicode evidence: café
+void use() {{ QQuickView view;
+ view.setSource(QUrl("{(root / 'Main.qml').as_uri()}"));
+ auto root = view.rootObject(); auto left = root->findChild("left");
+ {tail}
+}}'''
+
+
+def persisted(root, tail, qml=QML, *, extra=None, directed=False):
+ cpp = source(root, tail).replace("\n", "\r\n")
+ result = analysis(root, {"access.cpp": cpp, "Main.qml": qml, **(extra or {})})
+ graph = build_from_json(result, root=root, directed=directed)
+ output = root / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ reloaded = load_node_link_graph(payload)
+ site = sites(result, "qml_access")[-1]
+ md = qt_metadata(site)
+ assert (root / "access.cpp").read_bytes()[md["span"]["start_byte"]:md["span"]["end_byte"]].decode() == tail.split(";")[-2].strip()
+ links = [edge for edge in payload["links"] if edge["source"] == site["id"] and edge.get("context") in TARGET_CONTEXTS]
+ assert all(reloaded.has_edge(edge["source"], edge["target"]) for edge in links)
+ for edge in links:
+ attributes = reloaded.edges[edge["source"], edge["target"]]
+ assert attributes["source_file"] == "access.cpp" and attributes["source_location"] == site["source_location"]
+ assert qt_metadata(attributes)["span"] == md["span"]
+ assert attributes["relation"] == edge["relation"] and attributes["confidence"] == "INFERRED"
+ if not directed:
+ assert (attributes["_src"], attributes["_tgt"]) == (edge["source"], edge["target"])
+ return result, site, links
+
+
+@pytest.mark.parametrize("directed", [False, True])
+@pytest.mark.parametrize("tail", ['left->property("rootOnly");', 'left->setProperty("rootOnly", 9);',
+ 'QMetaObject::invokeMethod(left, "rootAction");'])
+def test_req_qml017_ac02_child_reflection_cannot_use_lexical_root(tmp_path, directed, tail):
+ _, site, links = persisted(tmp_path, tail, directed=directed)
+ assert not links and qt_metadata(site)["status"] != "resolved"
+
+
+@pytest.mark.parametrize("tail", ['left->findChild("right");',
+ 'root->findChild("deep", Qt::FindDirectChildrenOnly);',
+ 'root->findChild("owner");'])
+def test_req_qml017_ac02_findchild_rejects_siblings_depth_and_receiver(tmp_path, tail):
+ _, site, links = persisted(tmp_path, tail)
+ assert not links and qt_metadata(site)["status"] != "resolved"
+
+
+@pytest.mark.parametrize("tail, expected", [
+ ('left->property("localValue");', "localValue"), ('root->property("rootOnly");', "rootOnly"),
+ ('QMetaObject::invokeMethod(root, "rootAction");', "rootAction"),
+ ('left->findChild("deep");', "Item"),
+ ('root->findChild("left", Qt::FindDirectChildrenOnly);', "left"),
+ ('root->findChild("deep", Qt::FindChildrenRecursively);', "Item"),
+])
+def test_req_qml017_ac04_owned_members_and_subtree_targets_survive_reload(tmp_path, tail, expected):
+ result, site, links = persisted(tmp_path, tail)
+ assert qt_metadata(site)["status"] == "resolved" and len(links) == 1
+ target = next(node for node in result["nodes"] if node["id"] == links[0]["target"])
+ assert qml_metadata(target)["raw_name"] == expected
+
+
+def test_req_qml017_ac02_property_held_qobject_has_construction_owner(tmp_path):
+ qml = 'import QtQml\nQtObject { property QtObject child: QtObject { objectName: "details"; property int count: 1 } }'
+ result, site, links = persisted(tmp_path, 'auto child = root->findChild("details"); child->setProperty("count", 2);', qml)
+ assert qt_metadata(site)["status"] == "resolved" and len(links) == 1
+ child = next(node for node in result["nodes"] if qml_metadata(node).get("kind") == "object" and qml_metadata(node).get("parent_scope_key"))
+ md = qml_metadata(child)
+ assert md["construction_parent_kind"] == "property" and md["construction_parent_scope_key"] == md["parent_scope_key"]
+ assert "static_parent_scope_key" not in md
+
+
+@pytest.mark.parametrize("tail, expected", [
+ ('root->property("status");', "status"),
+ ('QMetaObject::invokeMethod(root, "refresh");', "refresh"),
+ ('auto child = root->findChild("details"); child->property("count");', "count"),
+])
+def test_req_qml017_ac04_native_base_members_and_property_child_keep_source_proof(tmp_path, tail, expected):
+ qml = ('import QtQml\nimport Public.Tools 1.0\nService { '
+ 'property QtObject child: QtObject { objectName: "details"; property int count: 1 } }')
+ extra = {"backend.h": 'class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service) '
+ 'Q_PROPERTY(int status READ status) public: int status() { return 1; } '
+ 'Q_INVOKABLE void refresh() {} };',
+ "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)'}
+ result, site, links = persisted(tmp_path, tail, qml, extra=extra)
+ assert qt_metadata(site)["status"] == "resolved" and len(links) == 1
+ target = next(node for node in result["nodes"] if node["id"] == links[0]["target"])
+ if expected == "count":
+ assert qml_metadata(target)["raw_name"] == expected
+ elif expected == "status":
+ assert qt_metadata(target)["raw_name"] == expected and target["source_file"] == "backend.h"
+ else:
+ member = next(node for node in result["nodes"] if qt_metadata(node).get("generic_target_id") == target["id"]
+ and qt_metadata(node).get("kind") == "member")
+ assert qt_metadata(member)["raw_name"] == expected and target["source_file"] == "backend.h"
+ assert links[0]["relation"] == "calls"
+
+
+@pytest.mark.parametrize("annotation", ["QML_UNCREATABLE(\"source-only\")", "QML_SINGLETON", "Q_GADGET"])
+def test_req_qml017_ac02_noncreatable_singleton_and_gadget_cannot_prove_qobject_creation(tmp_path, annotation):
+ qml = ('import QtQml\nimport Public.Tools 1.0\nService { '
+ 'property QtObject child: QtObject { objectName: "details" } }')
+ base = "" if annotation == "Q_GADGET" else " : public QObject"
+ extra = {"backend.h": 'class Backend' + base + ' { QML_NAMED_ELEMENT(Service) '
+ + (annotation if annotation == "Q_GADGET" else "Q_OBJECT " + annotation) + ' };',
+ "CMakeLists.txt": 'qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES backend.h)'}
+ _, site, links = persisted(tmp_path, 'root->findChild("details");', qml, extra=extra)
+ assert not links and qt_metadata(site)["status"] != "resolved"
+
+
+@pytest.mark.parametrize("write", [False, True])
+def test_req_qml017_ac02_inherited_member_is_receiver_owned_and_readonly_write_rejected(tmp_path, write):
+ qml = 'import QtQuick\nItem { property int rootOnly: 9; Base { objectName: "left" } }'
+ extra = {"Base.qml": 'import QtQuick\nItem { readonly property int inherited: 3; function action() {} }'}
+ tail = 'left->setProperty("inherited", 4);' if write else 'left->property("inherited");'
+ result, site, links = persisted(tmp_path, tail, qml, extra=extra)
+ if write:
+ assert not links and qt_metadata(site)["reason"] == "readonly_property"
+ else:
+ assert len(links) == 1 and qt_metadata(site)["status"] == "resolved"
+ assert next(node for node in result["nodes"] if node["id"] == links[0]["target"])["source_file"] == "Base.qml"
+
+
+def test_req_qml017_ac02_duplicate_names_are_ambiguous_only_inside_receiver(tmp_path):
+ qml = QML.replace('objectName: "deep";', 'objectName: "right";')
+ _, site, links = persisted(tmp_path, 'root->findChild("right");', qml)
+ assert not links and qt_metadata(site)["status"] == "ambiguous"
+ _, site, links = persisted(tmp_path, 'left->findChild("right");', qml)
+ assert len(links) == 1 and qt_metadata(site)["status"] == "resolved"
+
+
+@pytest.mark.parametrize("mutation", ['parent: right;', 'Component.onCompleted: left.parent = right;',
+ 'function move() { left.parent = right; }',
+ 'function move(key) { left[key] = right; }'])
+def test_req_qml017_ac02_parent_bindings_and_js_writes_do_not_authorize_construction_tree(tmp_path, mutation):
+ qml = QML.replace('objectName: "deep";', f'objectName: "deep"; {mutation}')
+ result, site, links = persisted(tmp_path, 'root->findChild("deep");', qml)
+ assert not links and qt_metadata(site)["reason"] == "component_parenting_mutated"
+ evidence = [qml_metadata(node)["construction_parent_mutation_spans"] for node in result["nodes"]
+ if qml_metadata(node).get("construction_parent_dynamic")]
+ assert evidence and all(spans == evidence[0] for spans in evidence)
+ span = evidence[0][0]
+ assert "parent" in (tmp_path / "Main.qml").read_bytes()[span["start_byte"]:span["end_byte"]].decode() or "[key]" in mutation
+
+
+@pytest.mark.parametrize("fragment", ['Unknown { objectName: "blocked" }',
+ 'Component { Item { objectName: "blocked" } }',
+ 'Loader { sourceComponent: Item { objectName: "blocked" } }'])
+def test_req_qml017_ac02_unknown_types_and_component_templates_remain_unavailable(tmp_path, fragment):
+ qml = 'import QtQuick\nItem { ' + fragment + ' }'
+ _, site, links = persisted(tmp_path, 'root->findChild("blocked");', qml)
+ assert not links and qt_metadata(site)["status"] != "resolved"
+
+
+@pytest.mark.parametrize("corrupt", ["scope", "owner", "source", "confidence", "origin", "parent_origin", "owner_span"])
+def test_req_qml017_ac02_construction_lookup_requires_accepted_declaration_proof(tmp_path, corrupt):
+ result = analysis(tmp_path, {"Main.qml": QML})
+ nodes, edges = copy.deepcopy(result["nodes"]), copy.deepcopy(result["edges"])
+ child = next(node for node in nodes if qml_metadata(node).get("object_id") == "left")
+ root = next(node for node in nodes if qml_metadata(node).get("object_id") == "owner")
+ declaration = next(edge for edge in edges if edge["target"] == child["id"] and qml_metadata(edge).get("kind") == "declaration")
+ if corrupt == "scope":
+ child["metadata"]["qml"] = encode_metadata({**qml_metadata(child), "construction_parent_scope_key": "absent"})
+ elif corrupt == "origin":
+ child["_origin"] = "llm"
+ elif corrupt == "parent_origin":
+ root["_origin"] = "llm"
+ elif corrupt == "owner_span":
+ root["metadata"]["qml"] = encode_metadata({**qml_metadata(root), "span": qml_metadata(child)["span"]})
+ else:
+ declaration[{"owner": "source", "source": "source_file", "confidence": "confidence"}[corrupt]] = "unaccepted"
+ before = copy.deepcopy((nodes, edges))
+ index = QtQmlAccessIndex(nodes, edges, root=tmp_path)
+ resolution = index.find_child(root["id"], "left")
+ assert resolution.target_id is None
+ assert (nodes, edges) == before
+
+
+def test_req_qml017_ac02_proved_cpp_parent_mutation_keeps_source_but_no_target(tmp_path):
+ _, site, links = persisted(tmp_path, 'left->setParent(other); root->findChild("deep");')
+ assert not links and qt_metadata(site)["status"] != "resolved"
+
+
+def test_req_qml017_ac02_unsupported_findchild_options_cannot_choose_target(tmp_path):
+ _, site, links = persisted(tmp_path, 'root->findChild("left", options);')
+ assert not links and qt_metadata(site)["reason"] == "find_child_options_unsupported"
+
+
+@pytest.mark.parametrize("target_type", ["QQuickItem*", "Custom*", "QObject"])
+def test_req_qml017_ac02_unproved_findchild_type_filter_cannot_choose_object(tmp_path, target_type):
+ """A QObject construction tree does not prove an arbitrary requested cast."""
+ _, site, links = persisted(tmp_path, f'root->findChild<{target_type}>("left");')
+ assert not links and qt_metadata(site)["reason"] == "find_child_type_unsupported"
diff --git a/tests/test_qt_reflection_type_aliases.py b/tests/test_qt_reflection_type_aliases.py
new file mode 100644
index 0000000000..5b6d3c9539
--- /dev/null
+++ b/tests/test_qt_reflection_type_aliases.py
@@ -0,0 +1,26 @@
+"""REQ-QML-017: a supported findChild filter needs SDK QObject identity."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_receiver_boundaries import persisted, source
+
+
+@pytest.mark.parametrize("declaration,filter_type,resolved", [
+ ("", "QObject", True),
+ ("", "::QObject", True),
+ ("class Other {}; using QObject = Other;", "QObject", False),
+ ("class Other {}; typedef Other QObject;", "QObject", False),
+ ("class QObject {};", "QObject", False),
+])
+def test_req_qml017_ac02_findchild_filter_rejects_shadowed_sdk_type(tmp_path, declaration, filter_type, resolved):
+ """Original pointer spelling cannot authorize a child cast to another class."""
+ tail = f'root->findChild<{filter_type}*>("child");'
+ result, site, links = persisted(tmp_path, tail,
+ 'import QtQuick\nItem { Item { objectName: "child" } }',
+ extra={"access.cpp": declaration + "\n" + source(tmp_path, tail)})
+ assert bool(links) is resolved
+ assert qt_metadata(site)["status"] == ("resolved" if resolved else "unsupported")
+ if not resolved:
+ assert qt_metadata(site)["reason"] == "find_child_type_unsupported"
diff --git a/tests/test_qt_reflection_type_identity.py b/tests/test_qt_reflection_type_identity.py
new file mode 100644
index 0000000000..4bb3571777
--- /dev/null
+++ b/tests/test_qt_reflection_type_identity.py
@@ -0,0 +1,204 @@
+"""REQ-QML-017-AC02/AC04: SDK reflection authority and persisted receiver targets.
+
+Public synthetic inputs reach extraction, build, JSON publication and reload. No
+Qt compiler, application, QML component or corpus build hook executes. The source
+class controls describe accepted parser inputs, not claims of successful Qt builds.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qml_resolution_types import qml_metadata
+from tests.qt_analysis_helpers import analysis, sites
+
+QML = 'import QtQml\nQtObject { property int count: 1; function refresh() {} }'
+OPERATIONS = {
+ "invoke": ('static void invokeMethod(QObject *, const char *) {}',
+ 'QMetaObject::invokeMethod(root, "refresh");', "QMetaObject"),
+ "static_read": ('static QVariant read(QObject *, const char *) {}',
+ 'QQmlProperty::read(root, "count");', "QQmlProperty"),
+ "static_write": ('static bool write(QObject *, const char *, QVariant) {}',
+ 'QQmlProperty::write(root, "count", 2);', "QQmlProperty"),
+ "property_handle": ('OtherProperty(QObject *, const char *) {} QVariant read() {}',
+ 'QQmlProperty handle(root, "count"); handle.read();', "QQmlProperty"),
+}
+TARGET_CONTEXTS = {"qt_cpp_qml_invoke", "qt_cpp_qml_property_read", "qt_cpp_qml_property_write"}
+
+
+def source(root, operation, shadow, *, absolute=False):
+ """A real source loader supplies the receiver; the reflection type varies."""
+ member, tail, sdk_name = OPERATIONS[operation]
+ definition, alias, namespace = "", "", ""
+ if shadow == "local_alias":
+ other = "OtherMeta" if sdk_name == "QMetaObject" else "OtherProperty"
+ definition = f"struct {other} {{ {member} }};"
+ alias = f"using {sdk_name} = {other};"
+ elif shadow in {"global_source_class", "namespace_source_class"}:
+ definition = f"struct {sdk_name} {{ {member.replace('OtherProperty(', 'QQmlProperty(')} }};"
+ namespace = "Public" if shadow == "namespace_source_class" else ""
+ elif shadow == "sdk_namespace_control":
+ namespace = "Public"
+ elif shadow == "forward_source_class":
+ definition = f"class {sdk_name};"
+ elif shadow == "conditional_alias":
+ definition = "struct Other {};"
+ alias = f"#if FEATURE\nusing {sdk_name} = Other;\n#endif\n"
+ elif shadow == "sdk_alias_control":
+ alias = f"using {sdk_name} = ::{sdk_name};"
+ elif shadow != "sdk_control":
+ raise ValueError(shadow)
+ if absolute:
+ tail = tail.replace(sdk_name, "::" + sdk_name, 1)
+ body = f'''// Original-byte evidence: café
+{definition}
+void use() {{ QQuickView view;
+ view.setSource(QUrl("{(root / 'Main.qml').as_uri()}"));
+ auto root = view.rootObject();
+ {alias}
+ {tail}
+}}'''
+ return "\ufeff" + (("namespace " + namespace + " {\n" + body + "\n}") if namespace else body).replace("\n", "\r\n")
+
+
+def persisted(root, operation, shadow, *, directed=False, absolute=False):
+ """Check actual durable direction and original source evidence before rejection."""
+ original = source(root, operation, shadow, absolute=absolute)
+ result = analysis(root, {"access.cpp": original, "Main.qml": QML})
+ graph = build_from_json(result, root=root, directed=directed)
+ output = root / "proof.json"
+ assert to_json(graph, {}, str(output), force=True)
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ restored = load_node_link_graph(payload)
+ accesses = sites(result, "qml_access")
+ by_id = {node["id"]: node for node in accesses}
+ for node in accesses:
+ metadata = qt_metadata(node)
+ span = metadata["span"]
+ text = original.encode()[span["start_byte"]:span["end_byte"]].decode()
+ assert text and text in original and "\r" not in text
+ assert node["source_location"].split("-", 1)[0] == "L" + str(original.encode()[:span["start_byte"]].count(b"\n") + 1)
+ assert node["source_file"] == "access.cpp"
+ assert node["source_location"].startswith("L")
+ links = [edge for edge in payload["links"] if edge["source"] in by_id
+ and edge.get("context") in TARGET_CONTEXTS]
+ for edge in links:
+ site = by_id[edge["source"]]
+ assert restored.has_edge(edge["source"], edge["target"])
+ actual = restored.edges[edge["source"], edge["target"]]
+ if directed:
+ assert not restored.has_edge(edge["target"], edge["source"])
+ else:
+ assert (actual["_src"], actual["_tgt"]) == (edge["source"], edge["target"])
+ assert actual["relation"] == ("calls" if operation == "invoke" else "uses")
+ assert actual["confidence"] == "INFERRED"
+ assert actual["source_file"] == "access.cpp"
+ assert actual["source_location"] == site["source_location"]
+ assert qt_metadata(actual)["span"] == qt_metadata(site)["span"]
+ return result, accesses, links, restored, output
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+@pytest.mark.parametrize("shadow", ["local_alias", "global_source_class", "namespace_source_class", "forward_source_class", "conditional_alias"])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml017_ac02_shadowed_reflection_type_cannot_create_qml_target(tmp_path, operation, shadow, directed):
+ """A local alias or source class spelling is not the Qt reflection SDK class."""
+ _, accesses, links, _, _ = persisted(tmp_path, operation, shadow, directed=directed)
+ assert not links, "Unrelated reflection class persisted a false QML member target"
+ rejected = [qt_metadata(node) for node in accesses if qt_metadata(node).get("reflection_supported") is False]
+ assert len(rejected) == 1
+ assert rejected[0]["status"] == "unsupported" and rejected[0]["reason"] == "reflection_api_type_unestablished"
+ assert all(qt_metadata(node)["status"] != "resolved" and not qt_metadata(node).get("target_id") for node in accesses)
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+@pytest.mark.parametrize("shadow, absolute", [("sdk_control", False), ("sdk_namespace_control", False),
+ ("local_alias", True), ("sdk_alias_control", False)])
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml017_ac04_unshadowed_sdk_reflection_preserves_targets_and_provenance(tmp_path, operation, shadow, absolute, directed):
+ """The correction must preserve genuine SDK calls, including namespace scope."""
+ result, accesses, links, _, _ = persisted(tmp_path, operation, shadow, absolute=absolute, directed=directed)
+ assert len(links) == (2 if operation == "property_handle" else 1)
+ assert accesses and all(qt_metadata(node)["status"] == "resolved" for node in accesses)
+ nodes = {node["id"]: node for node in result["nodes"]}
+ assert {qml_metadata(nodes[edge["target"]])["raw_name"] for edge in links} == {
+ "refresh" if operation == "invoke" else "count"}
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+def test_req_qml017_ac02_absolute_sdk_spelling_still_rejects_global_source_class(tmp_path, operation):
+ """Absolute qualification bypasses locals, never a source declaration at ::."""
+ _, accesses, links, _, _ = persisted(tmp_path, operation, "global_source_class", absolute=True)
+ assert not links and all(qt_metadata(node)["status"] != "resolved" for node in accesses)
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+def test_req_qml017_ac04_sdk_reflection_query_and_affected_use_exact_persisted_site(tmp_path, monkeypatch, capsys, operation):
+ """Consumers expose the same source-owned endpoint as the serialized graph."""
+ from graphify.affected import affected_nodes, load_graph
+ from tests.qt_consumer_helpers import cli
+ _, accesses, links, _, output = persisted(tmp_path, operation, "sdk_control", directed=True)
+ site = accesses[0]["id"]
+ target = links[0]["target"]
+ assert "access.cpp" in cli(monkeypatch, capsys, output, "explain", site)
+ assert "Main.qml" in cli(monkeypatch, capsys, output, "query", site)
+ relation = "calls" if operation == "invoke" else "uses"
+ assert site in {hit.node_id for hit in affected_nodes(load_graph(output), target, relations=[relation], depth=1)}
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+def test_req_qml017_ac02_direct_collector_carries_type_rejection_and_original_span(tmp_path, operation):
+ """The child collector owns authority before resolver/facade annotation."""
+ import copy
+ from graphify.extractors.qt_cpp_access import collect_qt_cpp_access
+ original = source(tmp_path, operation, "local_alias")
+ result = analysis(tmp_path, {"access.cpp": original, "Main.qml": QML})
+ borrowed = copy.deepcopy(result)
+ fresh = {"nodes": [], "edges": []}
+ collect_qt_cpp_access([tmp_path / "access.cpp"], [fresh], root=tmp_path,
+ accepted_nodes=result["nodes"], accepted_edges=result["edges"])
+ assert result == borrowed
+ rejected = [node for node in fresh["nodes"] if qt_metadata(node).get("reflection_supported") is False]
+ assert len(rejected) == 1
+ md = qt_metadata(rejected[0])
+ assert md["reflection_type"] == OPERATIONS[operation][2]
+ span = md["span"]
+ assert original.encode()[span["start_byte"]:span["end_byte"]].decode() in OPERATIONS[operation][1]
+ assert md["owner_id"] in {node["id"] for node in result["nodes"] if node.get("_callable")}
+
+
+@pytest.mark.parametrize("tail", [
+ 'QQmlProperty handle(root,"count"); QQmlProperty other(root,"count"); handle=other; handle.read();',
+ '{ QQmlProperty handle(root,"count"); handle.read(); } handle.read();',
+ 'QQmlProperty handle(root,"count"); handle=makeProperty(); handle.write(4);',
+])
+def test_req_qml017_ac02_property_handle_reassignment_and_lifetime_cannot_borrow_old_target(tmp_path, tail):
+ """A previously established property wrapper cannot authorize another value/scope."""
+ original = source(tmp_path, "invoke", "sdk_control").replace(OPERATIONS["invoke"][1], tail)
+ result = analysis(tmp_path, {"access.cpp": original, "Main.qml": QML})
+ site = sites(result, "qml_access")[-1]
+ metadata = qt_metadata(site)
+ assert metadata["status"] != "resolved" and not metadata.get("target_id")
+ graph = build_from_json(result, root=tmp_path)
+ assert not any(data.get("_src", start) == site["id"] and data.get("context") in TARGET_CONTEXTS
+ for start, _, data in graph.edges(data=True))
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+def test_req_qml017_ac02_sdk_identity_does_not_override_receiver_member_scope(tmp_path, operation):
+ """A genuine SDK API on a child still cannot access the root's members."""
+ original = source(tmp_path, operation, "sdk_control").replace(OPERATIONS[operation][1],
+ 'auto child = root->findChild("details"); ' + OPERATIONS[operation][1].replace("root,", "child,"))
+ qml = 'import QtQml\nQtObject { property int count:1; function refresh(){} '
+ qml += 'property QtObject child: QtObject { objectName:"details"; property int childOnly:2 } }'
+ result = analysis(tmp_path, {"access.cpp": original, "Main.qml": qml})
+ accesses = [node for node in sites(result, "qml_access") if qt_metadata(node).get("operation") != "findChild"]
+ assert accesses and all(qt_metadata(node)["status"] != "resolved" for node in accesses)
+ graph = build_from_json(result, root=tmp_path, directed=True)
+ ids = {node["id"] for node in accesses}
+ assert not any(start in ids and data.get("context") in TARGET_CONTEXTS for start, _, data in graph.edges(data=True))
diff --git a/tests/test_qt_reflection_updates.py b/tests/test_qt_reflection_updates.py
new file mode 100644
index 0000000000..bd1ea9bce3
--- /dev/null
+++ b/tests/test_qt_reflection_updates.py
@@ -0,0 +1,162 @@
+"""REQ-QML-017-AC04: SDK reflection refresh, rejection and durable recovery."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+from tests.test_qt_loader_updates import products
+from tests.test_qt_reflection_type_identity import OPERATIONS, QML, TARGET_CONTEXTS, source
+
+
+def cpp_source(root, operation, shadow="sdk_control"):
+ """Use a public accepted resource alias so metadata-only changes matter."""
+ return source(root, operation, shadow).replace((root / "Main.qml").as_uri(), "qrc:/ui/Main.qml").encode()
+
+
+def fixture(root, operation):
+ for name, content in {
+ "access.cpp": cpp_source(root, operation), "Main.qml": QML.encode(),
+ "CMakeLists.txt": b"qt_add_qml_module(app URI Public.Tools VERSION 1.0 QML_FILES Main.qml SOURCES access.cpp)\n",
+ "ui.qrc": b'Main.qml',
+ "keep.py": b"def helper(): return 7\n\ndef retained(): return helper()\n",
+ }.items():
+ (root / name).write_bytes(content)
+ return root / "access.cpp"
+
+
+def reflection(graph):
+ """Only the reflection access sites own these endpoints, never loader edges."""
+ sites = {identity: qt_metadata(data) for identity, data in graph.nodes(data=True)
+ if qt_metadata(data).get("kind") == "qml_access"}
+ edges = [(data.get("_src", a), data.get("_tgt", b), data)
+ for a, b, data in graph.edges(data=True)
+ if data.get("context") in TARGET_CONTEXTS and data.get("_src", a) in sites]
+ return sites, edges
+
+
+def accepted(graph, operation):
+ sites, edges = reflection(graph)
+ assert len(sites) == len(edges) == (2 if operation == "property_handle" else 1)
+ assert all(md["status"] == "resolved" for md in sites.values())
+ assert all(target == sites[identity]["target_id"] and graph.nodes[target]["source_file"] == "Main.qml"
+ for identity, target, _ in edges)
+ return sites, edges
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+@pytest.mark.parametrize("mode", ["manual", "watch"])
+def test_req_qml017_ac04_reflection_source_qml_metadata_edits_remove_stale_targets(tmp_path, monkeypatch, operation, mode):
+ """Cold/warm/full/update retain every public fact while authority changes."""
+ cpp = fixture(tmp_path, operation)
+ monkeypatch.chdir(tmp_path)
+ cold = clean(tmp_path, tmp_path / ".cold")
+ assert normalized(cold) == normalized(clean(tmp_path, tmp_path / ".cold"))
+ initial = run(tmp_path, monkeypatch, mode)
+ assert normalized(initial) == normalized(cold)
+ original_sites, _ = accepted(initial, operation)
+ untouched, valid_cpp = unrelated(initial), cpp.read_bytes()
+
+ cpp.write_bytes(cpp_source(tmp_path, operation, "local_alias"))
+ rejected = run(tmp_path, monkeypatch, mode, [cpp])
+ assert not reflection(rejected)[1]
+ assert all(md["status"] != "resolved" and not md.get("target_id") for md in reflection(rejected)[0].values())
+ assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".shadow-cold"))
+ cpp.write_bytes(valid_cpp)
+ assert normalized(run(tmp_path, monkeypatch, mode, [cpp])) == normalized(initial)
+
+ qml = tmp_path / "Main.qml"
+ valid_qml = qml.read_bytes()
+ qml.write_bytes(valid_qml.replace(b"count:", b"renamed:").replace(b"function refresh()", b"function revised()"))
+ qml_changed = run(tmp_path, monkeypatch, mode, [qml])
+ assert set(reflection(qml_changed)[0]) == set(original_sites) and cpp.read_bytes() == valid_cpp
+ assert not reflection(qml_changed)[1]
+ assert normalized(qml_changed) == normalized(clean(tmp_path, tmp_path / ".qml-cold"))
+ qml.write_bytes(valid_qml)
+ accepted(run(tmp_path, monkeypatch, mode, [qml]), operation)
+
+ resource = tmp_path / "ui.qrc"
+ valid_resource = resource.read_bytes()
+ resource.write_bytes(valid_resource.replace(b'alias="Main.qml"', b'alias="Other.qml"'))
+ metadata_changed = run(tmp_path, monkeypatch, mode, [resource])
+ assert set(reflection(metadata_changed)[0]) == set(original_sites)
+ assert not reflection(metadata_changed)[1] and cpp.read_bytes() == valid_cpp
+ assert normalized(metadata_changed) == normalized(clean(tmp_path, tmp_path / ".metadata-cold"))
+ resource.write_bytes(valid_resource)
+ accepted(run(tmp_path, monkeypatch, mode, [resource]), operation)
+
+ cpp.write_bytes(valid_cpp.replace(OPERATIONS[operation][1].encode(), b""))
+ removed = run(tmp_path, monkeypatch, mode, [cpp])
+ assert reflection(removed) == ({}, [])
+ assert normalized(removed) == normalized(clean(tmp_path, tmp_path / ".removed-cold"))
+ assert unrelated(removed) == untouched
+ durable = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(removed)
+ assert products(tmp_path) == durable
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+@pytest.mark.parametrize("mode", ["manual", "force", "watch"])
+def test_req_qml017_ac04_reflection_parse_failure_retains_four_products_and_recovers(tmp_path, monkeypatch, operation, mode):
+ """A real malformed reflected source fails before accepted publication."""
+ cpp = fixture(tmp_path, operation)
+ monkeypatch.chdir(tmp_path)
+ normal = "manual" if mode == "force" else mode
+ initial = run(tmp_path, monkeypatch, normal)
+ accepted(initial, operation)
+ valid, durable = cpp.read_bytes(), products(tmp_path)
+ cpp.write_bytes(valid + b"\r\nvoid broken({")
+ if mode == "watch":
+ from graphify.watch import _rebuild_code
+ assert not _rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True)
+ else:
+ with pytest.raises(SystemExit) as failure:
+ if mode == "force":
+ from tests.test_qt_cpp_upgrade_invalidation import cli
+ cli(tmp_path, monkeypatch, "update", force=True)
+ else:
+ run(tmp_path, monkeypatch, normal, [cpp])
+ assert failure.value.code == 1
+ assert products(tmp_path) == durable
+ cpp.write_bytes(valid)
+ recovered = run(tmp_path, monkeypatch, normal, [cpp])
+ assert normalized(recovered) == normalized(initial)
+ repeated = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, normal, [])) == normalized(recovered)
+ assert products(tmp_path) == repeated
+
+
+@pytest.mark.parametrize("operation", list(OPERATIONS))
+@pytest.mark.parametrize("mode", ["manual", "watch"])
+def test_req_qml017_ac04_reflection_write_failure_retains_four_products_and_retries(tmp_path, monkeypatch, operation, mode):
+ """Inject only the external replacement boundary after real analysis work."""
+ import graphify.watch as watch
+ cpp = fixture(tmp_path, operation)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, mode)
+ accepted(initial, operation)
+ durable = products(tmp_path)
+ cpp.write_bytes(cpp_source(tmp_path, operation, "local_alias"))
+ real_replace = watch.os_replace_with_fallback
+
+ def fail_replace(start, destination):
+ if str(destination).endswith("graph.json"):
+ raise PermissionError("injected reflection replacement failure")
+ return real_replace(start, destination)
+
+ with monkeypatch.context() as failure:
+ failure.setattr(watch, "os_replace_with_fallback", fail_replace)
+ if mode == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failure, mode, [cpp])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(tmp_path, changed_paths=[cpp], no_cluster=True)
+ assert products(tmp_path) == durable
+ recovered = run(tmp_path, monkeypatch, mode, [cpp])
+ assert not reflection(recovered)[1]
+ assert normalized(recovered) != normalized(initial)
+ assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".recovered-cold"))
+ repeated = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, mode, [])) == normalized(recovered)
+ assert products(tmp_path) == repeated
diff --git a/tests/test_qt_resource_resolution.py b/tests/test_qt_resource_resolution.py
new file mode 100644
index 0000000000..ecfe72a7d6
--- /dev/null
+++ b/tests/test_qt_resource_resolution.py
@@ -0,0 +1,189 @@
+"""QML-009-AC03 and QML-017: resource mapping, URL bases and accepted corpus."""
+import copy
+import json
+from pathlib import Path
+
+import pytest
+
+from graphify.extractors.qml import extract_qml
+from graphify.extractors.qml_cmake import extract_cmake, parse_cmake
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qml_qmake import extract_qmake, parse_qmake
+from graphify.extractors.qml_resources import extract_qrc, parse_qrc
+from graphify.qml_resolution_types import fact_node
+from graphify.qt_project_index import QtProjectIndex
+from graphify.security import sanitize_metadata
+
+
+def corpus(tmp_path, *, duplicate=False, locale=False):
+ source = tmp_path / "Main.qml"
+ source.write_bytes(b'Item { property int status: 1 }')
+ qml = extract_qml(source, root=tmp_path)
+ text = 'Main.qml'
+ if duplicate:
+ text += 'Main.qml'
+ text += ''
+ if locale:
+ text += 'Main.qml'
+ text += ''
+ qrc = parse_qrc(text)
+ assert not qrc.get("error")
+ return qml, qrc
+
+
+@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"])
+@pytest.mark.parametrize("newline", [b"\n", b"\r\n"])
+def test_qrc_original_byte_spans_utf8_aliases_and_roundtrip(tmp_path, bom, newline):
+ raw = bom + newline.join([b'',
+ ''.encode(), b'',
+ 'Main.qml'.encode(), b'']) + newline
+ path = tmp_path / "ui.qrc"
+ path.write_bytes(raw)
+ result = extract_qrc(path, root=tmp_path)
+ assert not result.get("error")
+ alias = next(n for n in result["nodes"] if qml_metadata(n)["kind"] == "resource_alias")
+ span = qml_metadata(alias)["span"]
+ assert raw[span["start_byte"]:span["end_byte"]] == 'Main.qml'.encode()
+ assert qml_metadata(alias)["logical_url"] == "qrc:/ui/Écran.qml"
+ assert qml_metadata(alias)["target_path"] == "Main.qml"
+ for node in result["nodes"]:
+ span = qml_metadata(node)["span"]
+ for end in ("start", "end"):
+ prefix = raw[:span[f"{end}_byte"]]
+ assert span[f"{end}_row"] == prefix.count(b"\n")
+ assert span[f"{end}_column"] == len(prefix) - prefix.rfind(b"\n") - 1
+
+
+def test_qrc_urls_resolve_only_accepted_component_and_explicit_runtime_base(tmp_path):
+ qml, qrc = corpus(tmp_path)
+ nodes, edges = qml["nodes"] + qrc["nodes"], qml["edges"] + qrc["edges"]
+ before = copy.deepcopy((nodes, edges))
+ index = QtProjectIndex(nodes, edges, root=tmp_path)
+ component = next(n for n in qml["nodes"] if qml_metadata(n)["kind"] == "component")
+ for url in ("qrc:/ui/Écran.qml", ":/ui/Écran.qml", "qrc:/ui/%C3%89cran.qml"):
+ assert index.resolve_url(url).target_id == component["id"]
+ assert index.resolve_url("Écran.qml", "qrc:/ui/Other.qml").target_id == component["id"]
+ assert index.resolve_url("Main.qml").reason == "runtime_url_base_unavailable"
+ assert index.resolve_url((tmp_path / "Main.qml").as_uri()).target_id == component["id"]
+ assert index.resolve_url("Main.qml", (tmp_path / "Other.qml").as_uri()).target_id == component["id"]
+ for base in ("qrc://other/ui/Other.qml", "qrc:/ui/Other.qml?variant=1",
+ "qrc:/ui/Other.qml#fragment", "qrc:/ui/../Other.qml"):
+ assert index.resolve_url("Écran.qml", base).target_id is None
+ assert (nodes, edges) == before
+ sanitized = json.loads(json.dumps({"nodes": nodes, "edges": edges}))
+ for item in sanitized["nodes"] + sanitized["edges"]:
+ item["metadata"] = sanitize_metadata(item["metadata"])
+ reload = QtProjectIndex(sanitized["nodes"], sanitized["edges"], root=tmp_path)
+ assert reload.resolve_url("qrc:/ui/Écran.qml") == index.resolve_url("qrc:/ui/Écran.qml")
+
+
+@pytest.mark.parametrize("duplicate,locale,status,reason", [
+ (True, False, "ambiguous", "duplicate_resource_alias"),
+ (False, True, "unsupported", "resource_locale_dependent"),
+])
+def test_qrc_duplicate_and_locale_variants_never_guess(tmp_path, duplicate, locale, status, reason):
+ qml, qrc = corpus(tmp_path, duplicate=duplicate, locale=locale)
+ result = QtProjectIndex(qml["nodes"] + qrc["nodes"], [], root=tmp_path).resolve_url("qrc:/ui/Écran.qml")
+ assert (result.status, result.reason, result.target_id) == (status, reason, None)
+
+
+@pytest.mark.parametrize("source,code", [
+ (']>&x;', "QML_QRC_ENTITY"),
+ (']>', "QML_QRC_ENTITY"),
+ ('../../host.qml', "QML_QRC_PATH"),
+ ('Main.qml', "QML_QRC_PATH"),
+ ('Main.qml', "QML_QRC_PATH"),
+ ('C:/host.qml', "QML_QRC_PATH"),
+ ('', "QML_QRC_SYNTAX"),
+])
+def test_qrc_host_reads_entities_traversal_and_malformed_xml_rejected(source, code):
+ result = parse_qrc(source)
+ assert result["qml_failures"]
+ assert result["diagnostics"][0]["code"] == code
+ assert not any(qml_metadata(n).get("kind") == "resource_alias" for n in result["nodes"])
+
+
+def test_qrc_missing_target_and_ignored_file_never_expand_corpus(tmp_path):
+ (tmp_path / "Main.qml").write_bytes(b"Item {}")
+ qrc = parse_qrc('Main.qml')
+ index = QtProjectIndex(qrc["nodes"], [], root=tmp_path)
+ assert index.resolve_url("qrc:/Main.qml").reason == "resource_target_outside_corpus"
+ for url in ("qrc:/ui/%2e%2e/Main.qml", "qrc://host/Main.qml", "https://host/Main.qml",
+ "qrc:/Main.qml?variant=1", "qrc:/Main.qml#fragment", "file:///host/Main.qml"):
+ assert index.resolve_url(url).target_id is None
+
+
+def test_literal_build_context_and_module_resource_component_mapping(tmp_path):
+ qml, _ = corpus(tmp_path)
+ cpp = fact_node("service.h", "file", "cpp", "service.h", 1)
+ cmake = parse_cmake("qt_add_qml_module(app URI Public.Tools VERSION 1.2 "
+ "RESOURCE_PREFIX /ui QML_FILES Main.qml SOURCES service.h)")
+ nodes = qml["nodes"] + cmake["nodes"] + [cpp]
+ index = QtProjectIndex(nodes, [], root=tmp_path)
+ context = index.module_context("service.h")
+ assert context.status == "resolved"
+ assert context.target_id is not None
+ assert index.module_metadata(context.target_id)["uri"] == "Public.Tools"
+ component = index.resolve_component("Public.Tools", "Main")
+ assert component.status == "resolved"
+ assert index.resolve_url("qrc:/ui/Public/Tools/Main.qml").target_id == component.target_id
+ assert index.resolve_url("Main.qml", "qrc:/ui/Public/Tools/Other.qml").target_id == component.target_id
+ assert index.resolve_url("qrc:/ui/Public/Tools/%4dain.qml").target_id == component.target_id
+ assert index.resolve_component("Public.Tools", "Main", 1, 1).target_id == component.target_id
+ assert index.resolve_component("Public.Tools", "Main", 2, 0).target_id is None
+ assert index.resolve_component("Other.Tools", "Main").target_id is None
+ assert index.module_import("Public.Tools", 1, 2).status == "resolved"
+ assert index.module_import("Public.Tools", 2, 0).target_id is None
+ assert index.module_context("other/service.h").target_id is None
+ other = parse_cmake("qt_add_qml_module(other URI Other.Tools VERSION 1.0 SOURCES service.h)",
+ relative_file="other.cmake")
+ conflict = QtProjectIndex(nodes + other["nodes"], [], root=tmp_path).module_context("service.h")
+ assert conflict.status == "ambiguous"
+ assert conflict.evidence
+ duplicate = parse_cmake("qt_add_qml_module(other URI Public.Tools VERSION 1.2 QML_FILES Main.qml)",
+ relative_file="other.cmake")
+ assert QtProjectIndex(nodes + duplicate["nodes"], [], root=tmp_path).module_import("Public.Tools").status == "ambiguous"
+
+
+def test_qmake_qrc_membership_resolves_the_same_component(tmp_path):
+ qml, qrc = corpus(tmp_path)
+ cpp = fact_node("service.h", "file", "cpp", "service.h", 1)
+ qmake = parse_qmake("QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.2\n"
+ "HEADERS += service.h\nRESOURCES += resources.qrc")
+ index = QtProjectIndex(qml["nodes"] + qrc["nodes"] + qmake["nodes"] + [cpp], [], root=tmp_path)
+ assert index.module_context("service.h").status == "resolved"
+ assert index.resolve_component("Public.Tools", "Main").target_id == index.resolve_url("qrc:/ui/Écran.qml").target_id
+
+
+def test_public_qt6_cmake_and_qmake_fixtures_describe_identical_membership():
+ root = Path(__file__).parent / "fixtures/qml/project_metadata"
+ qml = extract_qml(root / "Main.qml", root=root)
+ cpp = fact_node("backend.h", "file", "cpp", "backend.h", 1)
+ qrc = extract_qrc(root / "resources.qrc", root=root)
+ for metadata in (extract_cmake(root / "CMakeLists.txt", root=root),
+ extract_qmake(root / "tools.pro", root=root)):
+ assert not metadata.get("error")
+ index = QtProjectIndex(qml["nodes"] + qrc["nodes"] + metadata["nodes"] + [cpp], [], root=root)
+ context = index.module_context("backend.h")
+ assert context.target_id is not None
+ md = index.module_metadata(context.target_id)
+ assert (md["uri"], md["major"], md["minor"]) == ("Public.Tools", 1, 0)
+ assert index.resolve_component("Public.Tools", "Main").target_id == index.resolve_url("qrc:/ui/Public/Tools/Main.qml").target_id
+
+
+@pytest.mark.parametrize("same_file", [False, True])
+def test_same_target_name_does_not_coalesce_distinct_module_providers(tmp_path, same_file):
+ declaration = "qt_add_qml_module(app URI Public.Tools VERSION 1.0)\n"
+ if same_file:
+ results = [parse_cmake(declaration * 2)]
+ else:
+ results = [parse_cmake(declaration, relative_file=file) for file in
+ ("first/CMakeLists.txt", "second/CMakeLists.txt")]
+ nodes = [node for result in results for node in result["nodes"]]
+ providers = [node["id"] for node in nodes if qml_metadata(node).get("kind") == "qt_module"]
+ assert len(set(providers)) == 2
+ index = QtProjectIndex(nodes, [], root=tmp_path)
+ resolution = index.module_import("Public.Tools", 1, 0)
+ assert resolution.status == "ambiguous" and resolution.target_id is None
+ assert set(resolution.evidence) == set(resolution.candidates) == set(providers)
+ assert index.resolve_component("Public.Tools", "Main", 1, 0).status == "ambiguous"
diff --git a/tests/test_qt_semantic_correction_updates.py b/tests/test_qt_semantic_correction_updates.py
new file mode 100644
index 0000000000..6493c7e1ec
--- /dev/null
+++ b/tests/test_qt_semantic_correction_updates.py
@@ -0,0 +1,157 @@
+"""Real CLI/watch lifecycle for receiver, engine, alias and loader corrections."""
+from __future__ import annotations
+
+import pytest
+
+import graphify.extract as extraction
+import graphify.qt_incremental as policy
+from graphify.affected import affected_nodes, load_graph, resolve_seed
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+
+def fixture(root):
+ """A public resource fixture keeps native handles fixed while parenting changes."""
+ sources = {
+ "Main.qml": 'import QtQuick\nItem { property int value: 9; '
+ 'Item { objectName: "left"; property int value: 1; '
+ 'Item { objectName: "deep"; property int value: 2 } } }',
+ "access.cpp": 'void use() { QQuickView view; view.setSource(QUrl("qrc:/ui/Main.qml")); '
+ 'auto root = view.rootObject(); auto left = root->findChild("left"); '
+ 'left->property("value"); auto deep = left->findChild("deep"); }',
+ "ui.qrc": 'Main.qml',
+ "keep.py": 'def helper(): return 7\n\ndef retained(): return helper()\n',
+ }
+ for name, content in sources.items():
+ (root / name).write_text(content, encoding="utf-8", newline="")
+ return root / "Main.qml"
+
+
+def access(graph):
+ return {identity: qt_metadata(node) for identity, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == "qml_access"}
+
+
+def products(root):
+ output = root / "graphify-out"
+ return {name: (output / name).read_bytes() for name in
+ ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")}
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_receiver_tree_and_member_updates_remove_stale_edges(tmp_path, monkeypatch, operation):
+ """Lost child members cannot borrow the root; moved children cannot stay descendants."""
+ source = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ original_cpp = (tmp_path / "access.cpp").read_bytes()
+ initial = run(tmp_path, monkeypatch, operation)
+ before = access(initial)
+ assert len(before) == 3 and all(md["status"] == "resolved" for md in before.values())
+ assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold"))
+ assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold"))
+ consumer = load_graph(tmp_path / "graphify-out/graph.json")
+ for identity, md in before.items():
+ assert resolve_seed(initial, identity, tmp_path) == identity
+ assert identity in {hit.node_id for hit in affected_nodes(consumer, md["target_id"], relations=["uses"], depth=1)}
+ source.write_text('import QtQuick\nItem { property int value: 9; '
+ 'Item { objectName: "left" } '
+ 'Item { objectName: "deep"; property int value: 2 } }', encoding="utf-8")
+ edited = run(tmp_path, monkeypatch, operation, [source])
+ after = access(edited)
+ assert set(after) == set(before)
+ lost = [identity for identity, md in after.items() if md["status"] != "resolved"]
+ assert len(lost) == 2
+ for identity in lost:
+ assert not after[identity].get("target_id")
+ assert after[identity]["span"] == before[identity]["span"]
+ assert not any(data.get("_src", start) == identity and data.get("relation") == "uses"
+ for start, _, data in edited.edges(data=True))
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited"))
+ assert normalized(edited) == normalized(clean(tmp_path, tmp_path / ".edited"))
+ assert unrelated(initial) == unrelated(edited)
+ assert (tmp_path / "access.cpp").read_bytes() == original_cpp
+ assert normalized(run(tmp_path, monkeypatch, operation, [source])) == normalized(edited)
+ unchanged = products(tmp_path)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(edited)
+ assert products(tmp_path) == unchanged
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_policy_refresh_failure_retains_products_and_retry(tmp_path, monkeypatch, operation):
+ """A same-package policy upgrade and real parse failure retain every accepted product."""
+ source = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ with monkeypatch.context() as previous:
+ previous.setattr(policy, "QT_POLICY_VERSION", 6)
+ initial = run(tmp_path, previous, operation)
+ before, observed, real = products(tmp_path), [], extraction.extract
+
+ def observe(paths, **kwargs):
+ observed.extend(paths)
+ return real(paths, **kwargs)
+
+ monkeypatch.setattr(extraction, "extract", observe)
+ refreshed = run(tmp_path, monkeypatch, operation, [])
+ assert tmp_path / "access.cpp" in observed and source in observed
+ assert normalized(refreshed) == normalized(initial)
+ assert products(tmp_path)[".qt_analysis.json"] != before[".qt_analysis.json"]
+ valid, accepted = source.read_bytes(), products(tmp_path)
+ source.write_text("Item { property int value: }", encoding="utf-8")
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, monkeypatch, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ from graphify.watch import _rebuild_code
+ assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert products(tmp_path) == accepted
+ source.write_bytes(valid)
+ recovered = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(recovered) == normalized(refreshed)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered)
+
+
+@pytest.mark.parametrize("failure_stage", ["join", "replacement"])
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml017_ac04_failed_completion_keeps_prior_graph_and_retries(tmp_path, monkeypatch, operation, failure_stage):
+ """Real resolution/write work must complete before accepted graph/state replacement."""
+ import graphify.watch as watch
+ from graphify.qt_qml_access_index import QtQmlAccessIndex
+ from graphify.watch import _rebuild_code
+
+ source = fixture(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ initial = run(tmp_path, monkeypatch, operation)
+ accepted = products(tmp_path)
+ # The declared type is observable in source facts; numeric initializer
+ # evaluation is outside this analyzer and would not force a graph write.
+ source.write_bytes(source.read_bytes().replace(b"property int value: 1", b"property real value: 1"))
+ real_member, real_replace = QtQmlAccessIndex.member, watch.os_replace_with_fallback
+
+ def failed_member(*args, **kwargs):
+ assert real_member(*args, **kwargs).target_id
+ raise RuntimeError("injected resolution completion failure")
+
+ def failed_replace(start, destination):
+ if str(destination).endswith("graph.json"):
+ raise PermissionError("injected graph replacement failure")
+ return real_replace(start, destination)
+
+ with monkeypatch.context() as failed:
+ if failure_stage == "join":
+ failed.setattr(QtQmlAccessIndex, "member", failed_member)
+ else:
+ failed.setattr(watch, "os_replace_with_fallback", failed_replace)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, failed, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True)
+ assert products(tmp_path) == accepted
+ recovered = run(tmp_path, monkeypatch, operation, [source])
+ assert normalized(recovered) != normalized(initial)
+ assert normalized(recovered) == normalized(clean(tmp_path, tmp_path / ".repaired"))
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(recovered)
diff --git a/tests/test_qt_short_leaf_aliases.py b/tests/test_qt_short_leaf_aliases.py
new file mode 100644
index 0000000000..054ff0e56f
--- /dev/null
+++ b/tests/test_qt_short_leaf_aliases.py
@@ -0,0 +1,151 @@
+"""REQ-QML-020: Windows native file spellings preserve accepted source identities."""
+from __future__ import annotations
+
+import ctypes
+import json
+import os
+from pathlib import Path
+
+import pytest
+
+import graphify.extract as extraction
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.paths import load_node_link_graph
+from tests.test_qt_final_incremental_parity import normalized, project
+from tests.test_source_alias_provenance import directory_alias
+
+
+pytestmark = pytest.mark.skipif(os.name != "nt", reason="Actual Windows native short-file spelling")
+HEADER = "LongCanonicalBackendDeclaration.h"
+NAMES = (HEADER, "Main.qml", "loader.cpp", "CMakeLists.txt", "resources.qrc", "keep.py")
+PYTHON = b"def helper():\n return 7\n\ndef caller():\n return helper()\n"
+
+
+def short_leaf(path):
+ """Shorten the real leaf alone, retaining its walked parent-directory owner."""
+ api = ctypes.windll.kernel32.GetShortPathNameW
+ api.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
+ api.restype = ctypes.c_uint32
+ buffer = ctypes.create_unicode_buffer(32768)
+ length = api(str(path), buffer, len(buffer))
+ if not length or length >= len(buffer):
+ pytest.skip("Filesystem does not expose a native short-file spelling")
+ alias = path.parent / Path(buffer.value).name
+ if alias == path:
+ pytest.skip("Filesystem has no distinct short spelling for this leaf")
+ assert alias.resolve() == path.resolve()
+ return alias
+
+
+def fixture(root):
+ """Retain public Qt membership declarations and original BOM/CRLF source bytes."""
+ project(root)
+ old, header = root / "backend.h", root / HEADER
+ header.write_bytes(b"\xef\xbb\xbf// Unicode \xcf\x80\r\n" + old.read_bytes().replace(b"\n", b"\r\n"))
+ old.unlink()
+ cmake = root / "CMakeLists.txt"
+ cmake.write_bytes(cmake.read_bytes().replace(b"backend.h", HEADER.encode()))
+ return [root / name for name in NAMES]
+
+
+def graph(paths, root, cache):
+ result = extraction.extract(paths, root=root, cache_root=cache, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ return build_from_json(result, root=root, directed=True), result
+
+
+@pytest.mark.parametrize("selection", ["header", "build_metadata", "both"])
+def test_req_qml020_ac01_short_native_leaf_preserves_membership_facts_and_reload(
+ tmp_path, selection, monkeypatch):
+ """One NTFS entry has identical IDs, spans, memberships and cold/warm products."""
+ root = tmp_path.resolve()
+ paths = fixture(root)
+ spellings = {"header": {HEADER}, "build_metadata": {"CMakeLists.txt"},
+ "both": {HEADER, "CMakeLists.txt"}}[selection]
+ aliased = [short_leaf(path) if path.name in spellings else path for path in paths]
+ before = {name: (root / name).read_bytes() for name in NAMES}
+ expected, expected_result = graph(paths, root, root / "canonical-cache")
+ actual, result = graph(aliased, root, root / "alias-cache")
+ memberships = [qml_metadata(data) for _, data in actual.nodes(data=True)
+ if qml_metadata(data).get("kind") == "membership_resolution"]
+ assert memberships and all(item["status"] == "resolved" for item in memberships)
+ assert normalized(actual) == normalized(expected)
+ assert result["nodes"] == expected_result["nodes"] and result["edges"] == expected_result["edges"]
+ observed, real = [], extraction._safe_extract_with_xaml_root
+
+ def record(extractor, path, anchor):
+ observed.append(path.name)
+ return real(extractor, path, anchor)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", record)
+ warm, _ = graph(aliased, root, root / "alias-cache")
+ assert "keep.py" not in observed and normalized(warm) == normalized(expected)
+ output = root / "reloaded.json"
+ assert to_json(warm, {}, str(output), force=True)
+ reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert normalized(reloaded) == normalized(expected)
+ assert before == {name: (root / name).read_bytes() for name in NAMES}
+
+
+def test_req_qml020_ac02_short_spelling_preserves_two_discovered_junction_owners(
+ tmp_path, directory_alias):
+ """Native spelling expansion preserves separate source owners and actual calls."""
+ root = tmp_path.resolve()
+ paths = fixture(root)
+ real = root / "CanonicalRealDirectory"
+ real.mkdir()
+ source = real / "LongCanonicalFunctionSource.py"
+ source.write_bytes(PYTHON)
+ link = directory_alias(root / "DiscoveredJunctionDirectory", real)
+ walked = [source, link / source.name]
+ expected, _ = graph([*paths, *walked], root, root / "canonical-cache")
+ actual, result = graph([*paths, *(short_leaf(path) for path in walked)], root, root / "alias-cache")
+ identities = {
+ path.relative_to(root).as_posix(): {node["id"] for node in result["nodes"]
+ if node.get("source_file") == path.relative_to(root).as_posix()}
+ for path in walked
+ }
+ first, second = identities.values()
+ assert len(first) == len(second) == 3 and first.isdisjoint(second)
+ for source_file, owned in identities.items():
+ calls = [edge for edge in result["edges"] if edge.get("source_file") == source_file
+ and edge.get("context") == "call"]
+ assert calls and all(edge["source"] in owned and edge["target"] in owned for edge in calls)
+ assert normalized(actual) == normalized(expected)
+ warm, _ = graph([*paths, *(short_leaf(path) for path in walked)], root, root / "alias-cache")
+ assert normalized(warm) == normalized(expected)
+ assert source.read_bytes() == PYTHON
+
+
+def test_req_qml020_ac02_short_foreign_native_leaf_keeps_existing_root_rejection(
+ tmp_path, directory_alias):
+ """A local spelling of a foreign QObject still gains no Qt endpoint authority."""
+ root, foreign = tmp_path / "repo", tmp_path / "foreign"
+ root.mkdir()
+ foreign.mkdir()
+ paths = fixture(root)
+ target = foreign / "LongForeignBackendDeclaration.h"
+ target.write_bytes(b"class Foreign : public QObject { Q_OBJECT public: Q_INVOKABLE void run(); };\n")
+ link = directory_alias(root / "ForeignJunctionDirectory", foreign)
+ alias = short_leaf(link / target.name)
+ before = target.read_bytes()
+ assert extraction.collect_files(link, follow_symlinks=True, root=root) == []
+ result = extraction.extract([*paths, alias], root=root, cache_root=root / "cache", parallel=False)
+ assert result["failed_sources"] and {item["code"] for item in result["qml_failures"]} == {"QT_CPP_ROOT"}
+ assert not [node for node in result["nodes"] if node.get("label") == "Qt class: Foreign"]
+ assert target.read_bytes() == before
+
+
+def test_req_qml020_ac01_native_utf16_lengths_preserve_astral_source_names(tmp_path):
+ """Actual Win32 WCHAR lengths accept supplementary characters in owners and leaves."""
+ root = tmp_path.resolve()
+ parent = root / "PublicAstralDirectory\U0001f680"
+ parent.mkdir()
+ source = parent / "LongAstralFunctionSource\U0001f680.py"
+ source.write_bytes(PYTHON)
+ canonical, _ = graph([source], root, root / "canonical-cache")
+ actual, _ = graph([short_leaf(source)], root, root / "alias-cache")
+ assert normalized(actual) == normalized(canonical)
+ assert source.read_bytes() == PYTHON
diff --git a/tests/test_qt_signals_slots.py b/tests/test_qt_signals_slots.py
new file mode 100644
index 0000000000..0d76a045b1
--- /dev/null
+++ b/tests/test_qt_signals_slots.py
@@ -0,0 +1,72 @@
+"""QML-016 native event success, rejection and source-provenance acceptance."""
+import json
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis, sites
+
+SOURCE = '''class Sender : public QObject {
+ Q_OBJECT
+signals:
+ void changed(int value);
+public:
+ void run() { emit changed(1); Q_EMIT changed(2); changed(3); }
+};
+class Receiver : public QObject {
+ Q_OBJECT
+public:
+ void accept(int value) {}
+private slots:
+ void hidden(int value) {}
+};
+void wire(Sender *sender, Receiver *receiver) {
+ QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept, Qt::QueuedConnection);
+ QObject::connect(sender, SIGNAL(changed(int)), receiver, SLOT(hidden(int)));
+ QObject::connect(sender, &Sender::changed, receiver, [](int value) {}, Qt::UniqueConnection);
+ QObject::disconnect(sender, &Sender::changed, receiver, &Receiver::accept);
+}
+'''
+
+
+def test_native_events_have_distinct_sites_and_no_delivery_calls(tmp_path):
+ result = analysis(tmp_path, {"events.cpp": SOURCE})
+ emissions = sites(result, "emission")
+ assert len(emissions) == 3
+ assert all(qt_metadata(node)["status"] == "resolved" for node in emissions)
+ connections = sites(result, "connect")
+ assert len(connections) == 3
+ assert all(qt_metadata(node)["status"] == "resolved" for node in connections)
+ assert len(sites(result, "disconnect")) == 1
+ assert qt_metadata(sites(result, "disconnect")[0])["status"] == "resolved"
+ assert qt_metadata(connections[0])["declared_type"] == "QueuedConnection"
+ assert qt_metadata(connections[-1])["unique_connection_effect"] == "not_guaranteed_for_functors"
+ event_ids = {node["id"] for node in [*emissions, *connections]}
+ assert not [edge for edge in result["edges"] if edge["source"] in event_ids and edge["relation"] == "calls"]
+ assert len({node["id"] for node in emissions}) == 3
+ assert all(qt_metadata(node)["span"]["start_byte"] < qt_metadata(node)["span"]["end_byte"] for node in emissions)
+
+
+def test_macro_sections_private_meta_slots_and_comments(tmp_path):
+ source = SOURCE.replace("signals:", "Q_SIGNALS:").replace("private slots:", "private Q_SLOTS:")
+ source += '\n// QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept);\nconst char *text = "emit changed(9)";'
+ result = analysis(tmp_path, {"events.cpp": source})
+ assert len(sites(result, "connect")) == 3
+ hidden = [node for node in sites(result, "member") if qt_metadata(node)["raw_name"] == "hidden"]
+ assert len(hidden) == 1 and qt_metadata(hidden[0])["access"] == "private"
+ assert qt_metadata(hidden[0])["roles"] == ["slot"]
+
+
+def test_overloads_need_selector_and_dynamic_sender_stays_unresolved(tmp_path):
+ source = SOURCE.replace("void changed(int value);", "void changed(int value);\n void changed(double value);")
+ source = source.replace("&Sender::changed, receiver, &Receiver::accept, Qt::QueuedConnection", "qOverload(&Sender::changed), receiver, &Receiver::accept, Qt::QueuedConnection")
+ result = analysis(tmp_path, {"events.cpp": source})
+ connections = sites(result, "connect")
+ assert qt_metadata(connections[0])["status"] == "resolved"
+ assert qt_metadata(connections[2])["signal_status"] == "ambiguous"
+ result2 = analysis(tmp_path, {"events.cpp": SOURCE.replace("QObject::connect(sender, &Sender::changed, receiver, &Receiver::accept", "QObject::connect(factory(), &Sender::changed, receiver, &Receiver::accept")})
+ assert qt_metadata(sites(result2, "connect")[0])["status"] != "resolved"
+
+
+def test_nested_literal_metadata_round_trip_is_exact(tmp_path):
+ result = analysis(tmp_path, {"events.cpp": SOURCE.replace("int value", "QList value").replace("changed(int)", "changed(QList)").replace("hidden(int)", "hidden(QList)")})
+ node = sites(result, "connect")[1]
+ assert qt_metadata(json.loads(json.dumps(node)))["signal"]["parameter_types"] == ["QList"]
diff --git a/tests/test_qt_source_coverage_report.py b/tests/test_qt_source_coverage_report.py
new file mode 100644
index 0000000000..16dab65f9b
--- /dev/null
+++ b/tests/test_qt_source_coverage_report.py
@@ -0,0 +1,77 @@
+"""QML-010/013 reports distinguish source resolution coverage from Qt runtime proof."""
+import copy
+import json
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_coverage import source_coverage
+from graphify.report import generate
+from tests.qt_analysis_helpers import analysis, sites
+
+
+def report(graph):
+ return generate(graph, {0: list(graph)}, {0: 0.5}, {0: "Source facts"}, [], [],
+ {"total_files": 2, "total_words": 100, "warning": None}, {}, "public-project")
+
+
+def corpus(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use(QUrl computed){{ QQmlApplicationEngine engine;
+ engine.load(QUrl("{url}")); engine.load(computed);
+ engine.load(QUrl("https://example.test/Main.qml")); engine.load(QUrl("qrc:/missing.qml"));
+ engine.load(QUrl("qrc:/duplicate.qml")); }}'''
+ qrc = 'Main.qmlMain.qml'
+ return analysis(tmp_path, {"Main.qml": 'import QtQml\nQtObject { property int value: unknown }',
+ "use.cpp": source, "resources.qrc": qrc})
+
+
+def test_report_actual_source_status_counts_survive_json_reload_and_do_not_mutate_graph(tmp_path):
+ """Real supported, computed, duplicate and unavailable loads stay distinct."""
+ result = corpus(tmp_path)
+ graph = build_from_json(result, root=tmp_path)
+ states = {qt_metadata(node)["status"] for node in sites(result, "qml_load")}
+ assert states == {"resolved", "dynamic", "unsupported", "unavailable", "ambiguous"}
+ before = copy.deepcopy((dict(graph.nodes(data=True)), list(graph.edges(data=True))))
+ output = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(output), force=True)
+ loaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert source_coverage(loaded) == source_coverage(graph)
+ summary = report(loaded)
+ for state in ("Resolved", "Ambiguous", "Dynamic", "Unsupported", "Unavailable", "Error", "Other / no status"):
+ assert state in summary
+ row = source_coverage(graph)["qt"]
+ assert all(row[status] >= 1 for status in states)
+ assert "not a percentage of supported language syntax" in summary
+ assert "signal delivery, ordering, or thread safety" in summary
+ assert "absence from this graph is not proof" in summary
+ assert (dict(graph.nodes(data=True)), list(graph.edges(data=True))) == before
+
+
+def test_report_invalid_source_transport_counts_as_error_without_exposing_payload(tmp_path):
+ """Corrupt accepted-source metadata remains an explicit bounded reporting gap."""
+ result = corpus(tmp_path)
+ graph = build_from_json(result, root=tmp_path)
+ site = sites(result, "qml_load")[0]
+ raw = graph.nodes[site["id"]]["metadata"]["qt"]["raw_values"]
+ raw["raw_name"] = ""
+ count = source_coverage(graph)["qt"]
+ assert count["error"] == 1 and count["invalid_metadata"] == 1
+ summary = report(graph)
+ assert "1 source fact(s) have invalid semantic metadata" in summary
+ assert "private-payload" not in summary
+
+
+def test_report_future_or_unowned_metadata_cannot_claim_source_coverage(tmp_path):
+ """The new section stays absent for generic graphs and unsupported contracts."""
+ result = analysis(tmp_path, {"plain.py": 'def ordinary():\n return 1\n'})
+ graph = build_from_json(result, root=tmp_path)
+ assert "Qt/QML source coverage" not in report(graph)
+ node = next(iter(graph))
+ graph.nodes[node]["metadata"] = {"qt": {"contract_version": 2, "kind": "connect", "status": "resolved",
+ "span": {"start_byte": 0, "end_byte": 1}}}
+ assert not source_coverage(graph)
+ graph.nodes[node]["metadata"]["qt"]["contract_version"] = 1
+ graph.nodes[node]["metadata"]["qt"].pop("span")
+ assert not source_coverage(graph) and "Qt/QML source coverage" not in report(graph)
diff --git a/tests/test_qt_source_file_containment.py b/tests/test_qt_source_file_containment.py
new file mode 100644
index 0000000000..7867cf0465
--- /dev/null
+++ b/tests/test_qt_source_file_containment.py
@@ -0,0 +1,128 @@
+"""REQ-QML-008-AC02 retain source context without guessing native owners."""
+import copy
+import json
+from collections import Counter
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_source_containment import attach_qt_file_sites
+from tests.qt_analysis_helpers import analysis, sites
+
+
+HEADER = """class Backend : public QObject {
+ Q_OBJECT
+public:
+ Backend();
+ Backend(int count);
+signals:
+ void ready();
+};
+"""
+CPP = '#include "backend.hpp"\nBackend::Backend() : Backend(0) { emit ready(); }\n'
+
+
+def corpus(root):
+ """Duplicate equivalent constructor bodies cannot prove one source callable owner."""
+ cpp = CPP + CPP.split('\n', 1)[1]
+ result = analysis(root, {"backend.hpp": HEADER, "backend.cpp": cpp})
+ unknown = [node for node in sites(result, "member") + sites(result, "emission")
+ if not qt_metadata(node)["owner_id"]]
+ assert unknown
+ return result, unknown
+
+
+def test_req_qml008_ac02_unknown_native_owner_keeps_actual_file_context_after_reload(tmp_path):
+ """The production facade/build/writer retain truthful source context and unknown roles."""
+ # Equivalent duplicate definitions remain ambiguous after exact overload
+ # admission; a source file link cannot turn them into accepted native owners.
+ cpp = CPP + CPP.split('\n', 1)[1]
+ result = analysis(tmp_path, {"backend.hpp": HEADER, "backend.cpp": cpp})
+ unknown = [node for node in sites(result, "member") + sites(result, "emission")
+ if not qt_metadata(node)["owner_id"]]
+ assert unknown
+ for site in unknown:
+ assert any(edge["target"] == site["id"] and edge["context"] == "qt_source_file"
+ and edge["confidence"] == "EXTRACTED" for edge in result["edges"])
+ assert qt_metadata(site)["owner_id"] == ""
+ graph = build_from_json(result, root=tmp_path)
+ output = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(output))
+ reloaded = build_from_json(json.loads(output.read_text(encoding="utf-8")), root=tmp_path)
+ assert all(reloaded.degree(site["id"]) for site in unknown)
+ assert all(qt_metadata(reloaded.nodes[site["id"]])["owner_id"] == "" for site in unknown)
+ assert all(qt_metadata(site)["status"] != "resolved" for site in unknown
+ if qt_metadata(site)["kind"] == "emission")
+
+
+@pytest.mark.parametrize("corruption", [None, "missing", "foreign", "outside_root", "duplicate", "callable", "class", "non_ast", "unmarked", "fresh_unmarked"])
+def test_req_qml008_ac02_file_context_requires_unique_actual_file_role(tmp_path, corruption):
+ """Borrowed file identities cannot be guessed from a label or fabricated path ID."""
+ result, unknown = corpus(tmp_path)
+ nodes = copy.deepcopy(result["nodes"])
+ file = next(node for node in nodes if node.get("label") == "backend.cpp")
+ if corruption == "missing":
+ nodes.remove(file)
+ elif corruption == "foreign":
+ file["source_file"] = "outside.cpp"
+ elif corruption == "outside_root":
+ file["source_file"] = "../backend.cpp"
+ elif corruption == "duplicate":
+ nodes.append({**file, "id": file["id"] + "_competing"})
+ elif corruption == "callable":
+ file["_callable"] = True
+ elif corruption == "class":
+ file["_callable_class"] = True
+ elif corruption == "non_ast":
+ file["_origin"] = "llm"
+ elif corruption in {"unmarked", "fresh_unmarked"}:
+ file.pop("_origin", None)
+ before = copy.deepcopy(nodes)
+ fresh = {"nodes": copy.deepcopy(unknown), "edges": []}
+ metadata = copy.deepcopy(fresh["nodes"])
+ fresh_ids = {file["id"]} if corruption == "fresh_unmarked" else ()
+ added = attach_qt_file_sites({tmp_path / "backend.cpp": fresh}, nodes, [], root=tmp_path,
+ fresh_ast_ids=fresh_ids)
+ assert nodes == before and fresh["nodes"] == metadata
+ assert bool(added) == (corruption in {None, "fresh_unmarked"})
+ assert not attach_qt_file_sites({tmp_path / "backend.cpp": fresh}, nodes, added, root=tmp_path)
+ for edge in added:
+ assert edge["source"] == file["id"] and edge["context"] == "qt_source_file"
+ assert edge["_src"] == edge["source"] and edge["_tgt"] == edge["target"]
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac04_unowned_file_sites_refresh_and_remove_stale_links(tmp_path, monkeypatch, operation):
+ """Actual full/warm/update paths preserve Qt file links and evict removed sites.
+
+ Equivalent duplicate definitions keep visible source occurrences without a
+ guessed callable. Compare all nodes and every native edge; exact accepted
+ overload tests separately establish complete generic graph parity.
+ """
+ from tests.test_qt_final_incremental_parity import clean, endpoints, normalized, run, semantic
+
+ def native_snapshot(graph):
+ nodes, _ = normalized(graph)
+ native = Counter((*endpoints(graph, source, target, data),
+ json.dumps(semantic(data), sort_keys=True, ensure_ascii=False))
+ for source, target, data in graph.edges(data=True) if qt_metadata(data))
+ return nodes, native
+
+ corpus(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ cold = clean(tmp_path, tmp_path / ".cold-cache")
+ assert native_snapshot(clean(tmp_path, tmp_path / ".cold-cache")) == native_snapshot(cold)
+ initial = run(tmp_path, monkeypatch, operation)
+ assert native_snapshot(initial) == native_snapshot(cold)
+ retained = {identity for identity, data in initial.nodes(data=True)
+ if qt_metadata(data).get("kind") == "emission" and not qt_metadata(data)["owner_id"]}
+ assert retained and all(initial.degree(identity) for identity in retained)
+ path = tmp_path / "backend.cpp"
+ path.write_bytes(path.read_bytes().replace(b"emit ready();", b""))
+ changed = run(tmp_path, monkeypatch, operation, [path])
+ assert not retained.intersection(changed)
+ assert native_snapshot(changed) == native_snapshot(clean(tmp_path, tmp_path / ".changed-cache"))
+ assert native_snapshot(run(tmp_path, monkeypatch, operation, [])) == native_snapshot(changed)
diff --git a/tests/test_qt_source_identity_admission.py b/tests/test_qt_source_identity_admission.py
new file mode 100644
index 0000000000..3c8c6d96ce
--- /dev/null
+++ b/tests/test_qt_source_identity_admission.py
@@ -0,0 +1,76 @@
+"""INC-QML-50: physical admission precedes lexical source labels and diagnostics."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+
+import graphify.source_identity as input_identity
+from tests.test_qt_followed_metadata_alias import CASES, owned_facts, reader_for
+from tests.test_source_alias_provenance import directory_alias # noqa: F401
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX physical link/.. traversal semantics")
+@pytest.mark.parametrize("inside", [False, True])
+@pytest.mark.parametrize("name,key,text", CASES, ids=[case[0] for case in CASES])
+def test_req_qml020_ac02_dotdot_input_keeps_actual_physical_target_and_containment(
+ tmp_path, directory_alias, monkeypatch, inside, name, key, text):
+ """Actual symlink/.. input cannot borrow a lexical trap file's source name or root admission."""
+ root = tmp_path / "repo"
+ root.mkdir()
+ physical_parent = root / "real" if inside else tmp_path / "outside"
+ (physical_parent / "deep").mkdir(parents=True)
+ raw = b"\xef\xbb\xbf" + text.replace("\n", "\r\n").encode("utf-8")
+ (physical_parent / name).write_bytes(raw)
+ (root / name).write_bytes(b"\xfflexical-trap")
+ directory_alias(root / "link", physical_parent / "deep")
+ input_path = root / "link" / ".." / name
+ assert input_path.resolve() == physical_parent / name
+ opened, real_open = [], Path.open
+
+ def record(path, *args, **kwargs):
+ opened.append(path)
+ return real_open(path, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "open", record)
+ result = reader_for(key)(input_path, root=root)
+ if inside:
+ owned_facts(result, f"real/{name}", raw)
+ assert opened and all(path.resolve() == physical_parent / name for path in opened)
+ else:
+ assert result.get("error") and not result["nodes"] and not result["edges"]
+ assert not opened
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Native Windows source spelling admission")
+@pytest.mark.parametrize("name,key,text", [CASES[index] for index in (0, 1, 2, 4, 6, 7)],
+ ids=["qml", "qmldir", "cmake", "qmake", "qrc", "qmltypes"])
+def test_req_qml020_ac02_native_admission_failure_keeps_its_code_and_recovers(
+ tmp_path, monkeypatch, capsys, name, key, text):
+ """A failed real native API seam retains the existing code, omits backend bodies and repairs cleanly."""
+ source = tmp_path / name
+ raw = text.encode("utf-8")
+ source.write_bytes(raw)
+ reader = reader_for(key)
+ accepted = reader(source, root=tmp_path)
+ assert accepted["nodes"] and not accepted.get("error")
+ observed = []
+ private_body = "private-native-backend-body\nsecond-delimiter"
+
+ def failed_native(path):
+ observed.append(path)
+ raise OSError(private_body)
+
+ with monkeypatch.context() as failed:
+ failed.setattr(input_identity, "_native_long_name", failed_native)
+ result = reader(source, root=tmp_path)
+ assert observed and result.get("error") and not result["nodes"] and not result["edges"]
+ assert all(item["code"] == "SOURCE_INPUT_IDENTITY_FAILED" for item in result["diagnostics"])
+ assert all(item["source_file"] == "" for item in result["diagnostics"])
+ assert "QML_ROOT" not in repr(result)
+ assert all(fragment not in repr(result) for fragment in private_body.splitlines())
+ output = capsys.readouterr()
+ assert all(fragment not in output.out + output.err for fragment in private_body.splitlines())
+ assert reader(source, root=tmp_path) == accepted == reader(source, root=tmp_path)
+ assert source.read_bytes() == raw
diff --git a/tests/test_qt_source_links_upgrade.py b/tests/test_qt_source_links_upgrade.py
new file mode 100644
index 0000000000..e36647f6c5
--- /dev/null
+++ b/tests/test_qt_source_links_upgrade.py
@@ -0,0 +1,106 @@
+"""REQ-QML-011/012 refresh source containment and preserve durable products."""
+from __future__ import annotations
+
+import pytest
+
+import graphify.cache as cache
+import graphify.extract as extraction
+import graphify.qt_incremental as policy
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_analysis_state import QT_STATE_FILE, inspect_qt_analysis
+from tests.test_qt_cpp_upgrade_invalidation import cli, unchanged_python
+from tests.test_qt_final_incremental_parity import clean, normalized
+
+
+SOURCES = {
+ "helper.hpp": "class Helper { public: void run(int count); };\n",
+ "helper.cpp": '#include "helper.hpp"\nvoid Helper::run(int count) { Q_UNUSED(count) }\n',
+ "keep.py": "def helper(): return 7\n\ndef retained(): return helper()\n",
+}
+
+
+def prior_products(root, monkeypatch, epochs=(3, 6)):
+ """Publish real products under prior epochs, without replacing CLI behavior.
+
+ This proves upgrade/invalidation at the persisted boundary; separate source
+ regressions reproduce the actual old missing edges before correction.
+ """
+ paths = []
+ for name, content in SOURCES.items():
+ path = root / name
+ path.write_text(content, encoding="utf-8", newline="")
+ paths.append(path)
+ with monkeypatch.context() as historical:
+ historical.setattr(policy, "QT_POLICY_VERSION", epochs[0])
+ historical.setattr(cache, "_AST_CACHE_SCHEMA", epochs[1])
+ graph = cli(root, historical, "extract")
+ assert not inspect_qt_analysis(root, root / "graphify-out", paths).changed
+ prior_cache = cache.cache_dir(root)
+ return paths, graph, prior_cache
+
+
+@pytest.mark.parametrize("operation", ["extract", "update"])
+@pytest.mark.parametrize("epochs", [(3, 6), (4, 7)])
+def test_req_qml011_ac03_source_links_upgrade_reparses_unchanged_cpp(tmp_path, monkeypatch, operation, epochs):
+ """The same installed version cannot retain old orphan facts or old AST output."""
+ monkeypatch.chdir(tmp_path)
+ paths, before, old_cache = prior_products(tmp_path, monkeypatch, epochs)
+ sources = {path: path.read_bytes() for path in paths}
+ assert inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed
+ observed, real = [], extraction._safe_extract_with_xaml_root
+
+ def observe(extractor, path, root):
+ observed.append(path)
+ return real(extractor, path, root)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ current = cli(tmp_path, monkeypatch, operation)
+ assert set(paths[:2]).issubset(observed)
+ if epochs[1] != cache._AST_CACHE_SCHEMA:
+ assert not old_cache.exists()
+ assert not inspect_qt_analysis(tmp_path, tmp_path / "graphify-out", paths).changed
+ members = [(identity, qt_metadata(data)) for identity, data in current.nodes(data=True)
+ if qt_metadata(data).get("kind") == "member"]
+ assert len(members) == 1
+ identity, metadata = members[0]
+ assert not metadata["class_id"]
+ assert metadata["owner_id"] == metadata["generic_target_id"] and current.degree(identity)
+ assert unchanged_python(before) == unchanged_python(current)
+ assert sources == {path: path.read_bytes() for path in paths}
+ assert normalized(cli(tmp_path, monkeypatch, operation)) == normalized(current)
+ assert normalized(clean(tmp_path, tmp_path / "independent-cache")) == normalized(current)
+
+
+def test_req_qml011_ac04_removed_admission_removes_source_overlay(tmp_path, monkeypatch):
+ """Removing the last Qt marker removes derived links and matches a clean rebuild."""
+ monkeypatch.chdir(tmp_path)
+ prior_products(tmp_path, monkeypatch)
+ before = cli(tmp_path, monkeypatch, "update")
+ retired = {identity for identity, data in before.nodes(data=True) if qt_metadata(data)}
+ assert retired
+ (tmp_path / "helper.cpp").write_text(
+ '#include "helper.hpp"\nvoid Helper::run(int count) { (void)count; }\n', encoding="utf-8")
+ current = cli(tmp_path, monkeypatch, "update")
+ assert not retired.intersection(current)
+ assert unchanged_python(before) == unchanged_python(current)
+ assert normalized(clean(tmp_path, tmp_path / "independent-cache")) == normalized(current)
+ assert normalized(cli(tmp_path, monkeypatch, "update")) == normalized(current)
+
+
+@pytest.mark.parametrize("force", [False, True])
+def test_req_qml012_ac02_failed_source_links_upgrade_retains_products(tmp_path, monkeypatch, force):
+ """A malformed admitted implementation retains graph/stamp/root/manifest; retry recovers."""
+ monkeypatch.chdir(tmp_path)
+ paths, _, _ = prior_products(tmp_path, monkeypatch)
+ out = tmp_path / "graphify-out"
+ products = [out / "graph.json", out / "manifest.json", out / QT_STATE_FILE, out / ".graphify_root"]
+ before = {path: path.read_bytes() for path in products}
+ paths[1].write_text("void Helper::run( { Q_UNUSED(count) }\n", encoding="utf-8")
+ with pytest.raises(SystemExit) as failure:
+ cli(tmp_path, monkeypatch, "update", force=force)
+ assert failure.value.code not in (0, None)
+ assert before == {path: path.read_bytes() for path in products}
+ paths[1].write_text(SOURCES["helper.cpp"], encoding="utf-8")
+ recovered = cli(tmp_path, monkeypatch, "update")
+ assert not inspect_qt_analysis(tmp_path, out, paths).changed
+ assert normalized(cli(tmp_path, monkeypatch, "update")) == normalized(recovered)
diff --git a/tests/test_qt_source_state.py b/tests/test_qt_source_state.py
new file mode 100644
index 0000000000..ddc756d3d1
--- /dev/null
+++ b/tests/test_qt_source_state.py
@@ -0,0 +1,47 @@
+"""Production source-state transitions cannot borrow or retain false Qt identity."""
+import json
+
+from graphify.extract import extract
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis, sites
+from tests.test_qt_signals_slots import SOURCE
+
+
+def test_warm_source_results_match_and_reassigned_handle_loses_target(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void use() {{ QQuickView view; view.setSource(QUrl("{url}"));
+ auto root = view.rootObject(); root->property("value"); }}'''
+ sources = {"access.cpp": source, "Main.qml": 'import QtQml\nQtObject { property int value: 1 }'}
+ cold = analysis(tmp_path, sources)
+ warm = extract([tmp_path / name for name in sources], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert [(node["id"], qt_metadata(node)) for node in cold["nodes"] if qt_metadata(node)] == [(node["id"], qt_metadata(node)) for node in warm["nodes"] if qt_metadata(node)]
+ changed = analysis(tmp_path, {**sources, "access.cpp": source.replace('root->property', 'root = factory(); root->property')})
+ access = sites(changed, "qml_access")[0]
+ assert qt_metadata(access)["status"] == "dynamic"
+ # Declaration identity now rejects the observed write before the older
+ # handle-assignment check; retain its more precise reason and no target.
+ assert qt_metadata(access)["reason"] == "reassigned_declaration"
+ assert qt_metadata(access)["receiver_declaration_id"] == ""
+ assert not [edge for edge in changed["edges"] if edge["source"] == access["id"] and edge["relation"] in {"uses", "calls"}]
+
+
+def test_source_scopes_do_not_cross_between_functions_with_same_local_name(tmp_path):
+ url = (tmp_path / "Main.qml").as_uri()
+ source = f'''void first() {{ QQuickView view; view.setSource(QUrl("{url}")); auto root = view.rootObject(); }}
+void second(QObject *root) {{ root->property("value"); }}'''
+ result = analysis(tmp_path, {"access.cpp": source, "Main.qml": 'import QtQml\nQtObject { property int value: 1 }'})
+ assert qt_metadata(sites(result, "qml_access")[0])["status"] == "unavailable"
+
+
+def test_fresh_connection_borrows_prior_header_facts_without_mutation(tmp_path):
+ header, wire = SOURCE.split('void wire', 1)
+ initial = analysis(tmp_path, {"events.h": header, "wire.cpp": 'void wire' + wire})
+ borrowed_nodes = [node for node in initial["nodes"] if node["source_file"] == "events.h"]
+ borrowed_ids = {node["id"] for node in borrowed_nodes}
+ borrowed_edges = [edge for edge in initial["edges"] if edge["source"] in borrowed_ids and edge["target"] in borrowed_ids]
+ before = json.dumps([borrowed_nodes, borrowed_edges], sort_keys=True)
+ fresh = extract([tmp_path / "wire.cpp"], root=tmp_path, cache_root=tmp_path, parallel=False,
+ resolution_context_nodes=borrowed_nodes, resolution_context_edges=borrowed_edges)
+ assert not fresh.get("qml_failures")
+ assert all(qt_metadata(node)["status"] == "resolved" for node in sites(fresh, "connect"))
+ assert json.dumps([borrowed_nodes, borrowed_edges], sort_keys=True) == before
diff --git a/tests/test_qt_typed_provider_adoption.py b/tests/test_qt_typed_provider_adoption.py
new file mode 100644
index 0000000000..6eab032fd7
--- /dev/null
+++ b/tests/test_qt_typed_provider_adoption.py
@@ -0,0 +1,188 @@
+"""REQ-QML-018-AC03/AC04: declared factories and typed service paths."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.qt_analysis_helpers import analysis, sites
+from tests.qt_adoption_fixture import NATIVE, sources
+from tests.qt_adoption_fixture import QML
+
+
+@pytest.mark.parametrize("expression", ["factory->makeBackend()", "factory->backend"])
+def test_req_qml018_ac03_declared_expression_selects_backend_api(tmp_path, expression):
+ """A typed return/field lends its declared API, never runtime allocation evidence."""
+ result = analysis(tmp_path, sources(tmp_path, expression))
+ bindings = sites(result, "context_binding")
+ assert len(bindings) == 1
+ metadata = qt_metadata(bindings[0])
+ assert metadata["provider_class_name"] == "Backend"
+ assert metadata["provider_declaration_id"]
+ assert metadata.get("provider_type_evidence")
+
+
+@pytest.mark.parametrize("expression", ["backend", "factory->makeBackend()"])
+def test_req_qml018_ac04_typed_service_call_and_property_are_source_owned(tmp_path, expression):
+ """The child property's declared Service API owns refresh/count endpoints."""
+ result = analysis(tmp_path, sources(tmp_path, expression))
+ accesses = sites(result, "context_access")
+ by_id = {node["id"]: node for node in result["nodes"]}
+ targets = [by_id[qt_metadata(node)["target_id"]] for node in accesses]
+ proofs = [qt_metadata(node)["endpoint_proof"] for node in accesses]
+ assert any(qt_metadata(by_id[proof["member_fact_id"]]).get("raw_name") == "refresh"
+ and proof["canonical_target_id"] == qt_metadata(by_id[proof["member_fact_id"]])["generic_target_id"]
+ and by_id[proof["canonical_target_id"]].get("label") == ".refresh()" for proof in proofs)
+ assert any(qt_metadata(node).get("raw_name") == "count" for node in targets)
+ for access in accesses:
+ proof = qt_metadata(access).get("endpoint_proof", {})
+ assert proof.get("evidence") and proof.get("canonical_target_id")
+
+
+@pytest.mark.parametrize("expression", ["backend", "factory->makeBackend()"])
+def test_req_qml018_ac04_context_service_subscriptions_have_distinct_sites(tmp_path, expression):
+ """Connections and signal.connect preserve direction without delivery calls."""
+ result = analysis(tmp_path, sources(tmp_path, expression))
+ subscriptions = sites(result, "context_subscription")
+ assert len(subscriptions) == 2
+ assert {qt_metadata(node).get("subscription_form") for node in subscriptions} == {"Connections", "signal.connect"}
+ assert all(qt_metadata(node)["status"] == "resolved" for node in subscriptions)
+ assert not [edge for edge in result["edges"] if edge.get("context") == "qt_context_subscription" and edge.get("relation") == "calls"]
+
+
+@pytest.mark.parametrize("change", [
+ lambda native: native.replace("Backend *makeBackend()", "Unknown *makeBackend()"),
+ lambda native: native.replace("Backend *makeBackend()", "Backend *makeBackend(int value)"),
+ lambda native: native.replace("public: Backend *makeBackend()", "private: Backend *makeBackend()"),
+ lambda native: native.replace("Backend *makeBackend() { throw 7; }", "Backend *makeBackend(); Service *makeBackend();"),
+])
+def test_req_qml018_ac03_unknown_or_conflicting_factory_never_supplies_provider(tmp_path, change):
+ """Incomplete, overload, access and conflicting return evidence cannot guess a type."""
+ result = analysis(tmp_path, sources(tmp_path, native=change(NATIVE)))
+ assert not sites(result, "context_binding") and not sites(result, "context_access")
+
+
+@pytest.mark.parametrize("expression", ["factory->makeBackend(1)", "factory->missing", "factory->backend->missing()"])
+def test_req_qml018_ac03_unsupported_provider_expression_never_executes_or_guesses(tmp_path, expression):
+ """Unknown fields, arguments and undeclared intermediate methods remain opaque."""
+ result = analysis(tmp_path, sources(tmp_path, expression))
+ assert not sites(result, "context_binding")
+
+
+def test_req_qml018_ac03_multiple_declared_expression_hops_lend_service_api(tmp_path):
+ """A public field followed by its public getter lends the final declared API."""
+ result = analysis(tmp_path, sources(tmp_path, "factory->backend->service()"))
+ binding = sites(result, "context_binding")[0]
+ assert qt_metadata(binding)["provider_class_name"] == "Service"
+
+
+def test_req_qml018_ac03_conditional_provider_declaration_is_not_api_authority(tmp_path):
+ """Accepted syntax in an unevaluated preprocessor branch cannot expose a provider."""
+ native = "#if FEATURE\n" + NATIVE + "\n#endif\n"
+ assert not sites(analysis(tmp_path, sources(tmp_path, "backend", native=native)), "context_binding")
+
+
+@pytest.mark.parametrize("mutation", ["reassign", "conditional", "private_field"])
+def test_req_qml018_ac03_root_identity_and_member_access_are_required(tmp_path, mutation):
+ """Declared return type cannot repair a changed lifetime, condition or private field."""
+ inputs = sources(tmp_path, "factory->backend" if mutation == "private_field" else "factory->makeBackend()")
+ if mutation == "reassign":
+ inputs["access.cpp"] = inputs["access.cpp"].replace(" QQmlApplicationEngine", " factory = nullptr; QQmlApplicationEngine")
+ elif mutation == "conditional":
+ inputs["access.cpp"] = inputs["access.cpp"].replace("engine.rootContext()", "if (factory) engine.rootContext()")
+ else:
+ inputs["native.h"] = inputs["native.h"].replace(" Backend *backend;", "private: Backend *backend;")
+ assert not sites(analysis(tmp_path, inputs), "context_binding")
+
+
+@pytest.mark.parametrize("change", [
+ lambda text: text.replace("Q_PROPERTY(Service* service", "Q_PROPERTY(Unknown* service"),
+ lambda text: text.replace("Service *service()", "Unknown *service()"),
+ lambda text: text.replace("Q_PROPERTY(Service* service READ service)",
+ "#if FEATURE\n Q_PROPERTY(Service* service READ service)\n#endif"),
+ lambda text: text.replace("Q_INVOKABLE void refresh() {}", "Q_INVOKABLE void refresh() {} Q_INVOKABLE void refresh(int count) {}"),
+])
+def test_req_qml018_ac04_unproven_child_types_or_overloads_do_not_select_terminal_api(tmp_path, change):
+ """Property and getter agree on one type; terminal overloads remain unresolved."""
+ result = analysis(tmp_path, sources(tmp_path, native=change(NATIVE)))
+ assert sites(result, "context_binding")
+ assert not any(qt_metadata(node).get("endpoint_proof", {}).get("kind") == "function"
+ for node in sites(result, "context_access"))
+
+
+def test_req_qml018_ac04_ordinary_method_is_not_a_signal_subscription(tmp_path):
+ """A same-name invokable does not lend Qt signal semantics to connect syntax."""
+ result = analysis(tmp_path, sources(tmp_path, native=NATIVE.replace("signals: void ready();", "public: Q_INVOKABLE void ready();")))
+ assert not sites(result, "context_subscription")
+
+
+@pytest.mark.parametrize("mutation,expected", [("root", 1), ("callback", 1), ("own_property", 0)])
+def test_req_qml018_ac04_qml_lexical_and_property_shadows_precede_context_provider(tmp_path, mutation, expected):
+ """Parameter/callback scopes and own properties prevent provider fallback."""
+ qml = QML.replace("function run()", "function run(backend)" if mutation == "root" else
+ "function run(handleReady)" if mutation == "callback" else "function run()")
+ if mutation == "own_property":
+ qml = qml.replace("QtObject {", "QtObject { property QtObject backend: QtObject {}", 1)
+ result = analysis(tmp_path, sources(tmp_path, qml=qml))
+ assert len(sites(result, "context_subscription")) == expected
+
+
+@pytest.mark.parametrize("style,expected", [("legacy", False), ("function", True), ("arrow", True)])
+def test_req_qml018_ac04_native_implicit_parameters_do_not_bind_explicit_handlers(tmp_path, style, expected):
+ """Only legacy blocks inherit native signal names; explicit formals stay local."""
+ handler = {"legacy": "onReady: { count }", "function": "function onReady(value) { count }",
+ "arrow": "onReady: (value) => { count }"}[style]
+ qml = 'import QtQml\nQtObject { property int count: 2; property Connections sub: Connections {'
+ qml += 'target: backend.service; ' + handler + ' } }'
+ native = NATIVE.replace("void ready();", "void ready(int count);")
+ result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native))
+ assert len(sites(result, "context_subscription")) == 1
+ reads = [node for node in result["nodes"] if node.get("metadata", {}).get("qml", {}).get("kind") == "read"]
+ from graphify.extractors.qml_facts import qml_metadata
+ count = next(node for node in reads if qml_metadata(node).get("reference") == "count")
+ actual = any(edge["source"] == count["id"] and edge.get("context") == "qml_binding_read"
+ for edge in result["edges"])
+ assert actual is expected
+
+
+@pytest.mark.parametrize("path", ["backend.service.parent.service.count", "backend." + "service." * 9 + "count"])
+def test_req_qml018_ac04_cyclic_and_oversized_child_paths_stay_unresolved(tmp_path, path):
+ """Cycle and chain limits cannot resolve by choosing an earlier convenient type."""
+ native = "class Backend;\n" + NATIVE.replace(" Q_PROPERTY(int count READ count)",
+ " Q_PROPERTY(int count READ count) Q_PROPERTY(Backend* parent READ parent)")
+ native = native.replace("public: int count()", "public: Backend* parent() { throw 7; } int count()")
+ qml = "import QtQml\nQtObject { property int count: " + path + " }"
+ result = analysis(tmp_path, sources(tmp_path, qml=qml, native=native))
+ assert not sites(result, "context_access")
+
+
+@pytest.mark.parametrize("callback,expected", [("/* callback */ handleReady", 2), ("() => {}", 1), ("handleReady, 7", 1)])
+def test_req_qml018_ac04_callback_ast_comment_and_unsupported_forms_are_distinct(tmp_path, callback, expected):
+ """Comments preserve a literal callback; arbitrary expressions do not establish it."""
+ qml = QML.replace("ready.connect(handleReady)", "ready.connect(" + callback + ")")
+ result = analysis(tmp_path, sources(tmp_path, qml=qml))
+ assert len(sites(result, "context_subscription")) == expected
+
+
+def test_req_qml018_ac04_child_property_changed_handler_uses_real_notify_signal(tmp_path):
+ """A context child property's change handler retains its differently named NOTIFY."""
+ native = NATIVE.replace("Q_PROPERTY(int count READ count)", "Q_PROPERTY(int count READ count NOTIFY actualCount)")
+ native = native.replace("signals: void ready();", "signals: void ready(); void actualCount();")
+ qml = QML.replace("function onReady()", "function onCountChanged()")
+ result = analysis(tmp_path, sources(tmp_path, native=native, qml=qml))
+ subscriptions = sites(result, "context_subscription")
+ assert len(subscriptions) == 2
+ nodes = {node["id"]: node for node in result["nodes"]}
+ handler = next(node for node in subscriptions if qt_metadata(node)["subscription_form"] == "Connections")
+ proof = qt_metadata(handler)["endpoint_proof"]
+ assert qt_metadata(nodes[proof["member_fact_id"]])["raw_name"] == "actualCount"
+
+
+def test_req_qml018_ac03_duplicate_typed_context_exposures_remain_ambiguous(tmp_path):
+ """Two independent exposures cannot choose a convenient provider occurrence."""
+ inputs = sources(tmp_path)
+ fragment = 'engine.rootContext()->setContextProperty("backend", factory->makeBackend());'
+ inputs["access.cpp"] = inputs["access.cpp"].replace(fragment, fragment + fragment)
+ result = analysis(tmp_path, inputs)
+ bindings = sites(result, "context_binding")
+ assert len(bindings) == 2 and all(qt_metadata(node)["status"] == "ambiguous" for node in bindings)
+ assert not sites(result, "context_access") and not sites(result, "context_subscription")
diff --git a/tests/test_qt_typed_provider_consumers.py b/tests/test_qt_typed_provider_consumers.py
new file mode 100644
index 0000000000..4e75141276
--- /dev/null
+++ b/tests/test_qt_typed_provider_consumers.py
@@ -0,0 +1,73 @@
+"""REQ-QML-018-AC03/AC04/AC06: declared chains retain guarded consumer evidence."""
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import encode_qt, qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.qt_qml_projection import allows_qt_qml_edge
+from tests.qt_analysis_helpers import analysis
+from tests.qt_adoption_fixture import sources
+
+
+def bridge_edges(result):
+ return [edge for edge in result["edges"] if edge.get("context") in
+ {"qt_context_member", "qt_context_subscription"}]
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml018_ac04_typed_subscriptions_survive_build_reload_and_query(tmp_path, directed):
+ """Producer direction reaches the native signal and QML handler through distinct sites."""
+ result = analysis(tmp_path, sources(tmp_path))
+ expected = bridge_edges(result)
+ assert len(expected) == 5
+ graph = build_from_json(result, root=tmp_path, directed=directed)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path), force=True)
+ restored = load_node_link_graph(json.loads(path.read_text(encoding="utf-8")))
+ for edge in expected:
+ assert restored.has_edge(edge["source"], edge["target"])
+ metadata = restored.edges[edge["source"], edge["target"]]
+ assert qt_metadata(metadata)["endpoint_proof"] == qt_metadata(edge)["endpoint_proof"]
+ if not directed:
+ assert (metadata["_src"], metadata["_tgt"]) == (edge["source"], edge["target"])
+ from graphify.serve import _query_graph_text
+ answer = _query_graph_text(restored, "native_service_ready", token_budget=8000)
+ assert "qt_context_subscription" in answer and "Main.qml" in answer and "references" in answer
+
+
+@pytest.mark.parametrize("corruption", ["hop", "accessor", "provider", "class", "handler", "role", "transport"])
+def test_req_qml018_ac06_corrupted_endpoint_or_chain_is_rejected_at_consumer(tmp_path, corruption):
+ """A copied serialized bridge cannot lend authority to altered declarations or roles."""
+ result = analysis(tmp_path, sources(tmp_path))
+ nodes, edges = copy.deepcopy((result["nodes"], result["edges"]))
+ by_id = {node["id"]: node for node in nodes}
+ edge = next(edge for edge in edges if edge.get("context") == "qt_context_subscription")
+ source, target = by_id[edge["source"]], by_id[edge["target"]]
+ md = qt_metadata(source)
+ proof = qt_metadata(edge)["endpoint_proof"]
+ if corruption == "hop":
+ proof["type_chain"][0]["target_class_id"] = md["binding_id"]
+ fields = {key: value for key, value in qt_metadata(edge).items() if key != "raw_values"}
+ fields["endpoint_proof"] = proof
+ edge["metadata"]["qt"] = encode_qt(fields)
+ else:
+ chosen = (by_id[proof["type_chain"][0]["property_id"]] if corruption == "accessor" else
+ by_id[md["binding_id"]] if corruption == "provider" else
+ by_id[proof["class_id"]] if corruption == "class" else
+ by_id[proof["member_fact_id"]] if corruption in {"role", "transport"} else source)
+ if corruption == "class":
+ chosen["_callable_class"] = False
+ elif corruption == "transport":
+ chosen["metadata"]["qt"]["raw_values"]["raw_name"] = "invalid!"
+ else:
+ fields = {key: value for key, value in qt_metadata(chosen).items() if key != "raw_values"}
+ fields[{"accessor": "api_type_target_id", "provider": "provider_type_evidence",
+ "handler": "handler_target_id", "role": "roles"}[corruption]] = [] if corruption in {"provider", "role"} else "missing"
+ chosen["metadata"]["qt"] = encode_qt(fields)
+ assert not allows_qt_qml_edge(source, target, edge, source_id=source["id"], target_id=target["id"], nodes=by_id)
diff --git a/tests/test_qt_typed_provider_updates.py b/tests/test_qt_typed_provider_updates.py
new file mode 100644
index 0000000000..8aa1ca3c07
--- /dev/null
+++ b/tests/test_qt_typed_provider_updates.py
@@ -0,0 +1,171 @@
+"""REQ-QML-018-AC03--AC06: typed providers through real accepted updates."""
+from __future__ import annotations
+
+import pytest
+
+from graphify.cache import cache_dir
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.watch import _rebuild_code
+from tests.qt_adoption_fixture import sources
+from tests.test_qt_final_incremental_parity import clean, normalized, run, unrelated
+
+
+PRODUCTS = ("graph.json", "manifest.json", ".qt_analysis.json", ".graphify_root")
+
+
+def corpus(root):
+ """A public qmake module supplies the engine load; declaration facts supply APIs."""
+ files = sources(root)
+ beginning = files["access.cpp"].split('engine.load(QUrl("')[0]
+ files["access.cpp"] = beginning + 'engine.loadFromModule("Public.Tools", "Main"); }\n'
+ files["tools.pro"] = 'QML_IMPORT_NAME = Public.Tools\nQML_IMPORT_VERSION = 1.0\n'
+ files["tools.pro"] += 'HEADERS += $$PWD/native.h\nQML_FILES += $$PWD/Main.qml\n'
+ files["keep.py"] = 'def helper(): return 7\n\ndef retained(): return helper()\n'
+ for name, text in files.items():
+ (root / name).write_bytes(text.encode())
+
+
+def facts(graph, kind):
+ return {identity: qt_metadata(node) for identity, node in graph.nodes(data=True)
+ if qt_metadata(node).get("kind") == kind}
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac05_cpp_qml_metadata_and_signal_updates_match_clean_rebuild(tmp_path, monkeypatch, operation):
+ """Real provider/property/signal and metadata mutations retire stale accepted sites."""
+ corpus(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert len(facts(initial, "context_subscription")) == 2
+ assert normalized(initial) == normalized(clean(tmp_path, tmp_path / ".cold"))
+ assert normalized(clean(tmp_path, tmp_path / ".cold")) == normalized(initial)
+ preserved = unrelated(initial)
+ header, qml, build = (tmp_path / name for name in ("native.h", "Main.qml", "tools.pro"))
+ original_header, original_qml, original_build = header.read_bytes(), qml.read_bytes(), build.read_bytes()
+
+ # C++-only return-type loss rejects the factory without a name-based fallback.
+ header.write_bytes(original_header.replace(b"Backend *makeBackend()", b"Unknown *makeBackend()"))
+ rejected = run(tmp_path, monkeypatch, operation, [header])
+ assert not facts(rejected, "context_binding")
+ assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".unknown-return"))
+ header.write_bytes(original_header)
+ assert normalized(run(tmp_path, monkeypatch, operation, [header])) == normalized(initial)
+
+ # A declared child API rename invalidates unchanged QML service paths.
+ header.write_bytes(original_header.replace(b"Service* service READ service", b"Service* child READ service"))
+ no_child = run(tmp_path, monkeypatch, operation, [header])
+ assert not facts(no_child, "context_subscription")
+ assert normalized(no_child) == normalized(clean(tmp_path, tmp_path / ".child-rename"))
+ qml.write_bytes(original_qml.replace(b"backend.service", b"backend.child"))
+ rebound = run(tmp_path, monkeypatch, operation, [qml])
+ assert len(facts(rebound, "context_subscription")) == 2
+ assert normalized(rebound) == normalized(clean(tmp_path, tmp_path / ".qml-rebind"))
+
+ # Both subscription forms lose a removed signal, retaining ordinary calls.
+ header.write_bytes(header.read_bytes().replace(b"signals: void ready();", b""))
+ no_signal = run(tmp_path, monkeypatch, operation, [header])
+ assert not facts(no_signal, "context_subscription")
+ assert facts(no_signal, "context_access")
+ assert normalized(no_signal) == normalized(clean(tmp_path, tmp_path / ".signal-removed"))
+ header.write_bytes(original_header)
+ qml.write_bytes(original_qml)
+ assert normalized(run(tmp_path, monkeypatch, operation, [header, qml])) == normalized(initial)
+
+ # Metadata-only URI changes invalidate the source-established loaded scope.
+ build.write_bytes(original_build.replace(b"Public.Tools", b"Public.Other"))
+ no_component = run(tmp_path, monkeypatch, operation, [build])
+ assert not facts(no_component, "context_binding")
+ assert normalized(no_component) == normalized(clean(tmp_path, tmp_path / ".module-removed"))
+ build.write_bytes(original_build)
+ final = run(tmp_path, monkeypatch, operation, [build])
+ assert normalized(final) == normalized(initial) and unrelated(final) == preserved
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(final)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("failure", ["cpp", "qml", "resolver"])
+def test_req_qml018_ac06_parser_and_resolver_failure_preserve_then_recover(tmp_path, monkeypatch, operation, failure):
+ """A real source parse failure or owning resolver exception retains committed products."""
+ corpus(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ output = tmp_path / "graphify-out"
+ before = {name: (output / name).read_bytes() for name in PRODUCTS}
+ cache = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*") if path.is_file()}
+ path = tmp_path / ("native.h" if failure in {"cpp", "resolver"} else "Main.qml")
+ valid = path.read_bytes()
+ path.write_bytes(valid + (b"class Broken {\n" if failure == "cpp" else b"Item {\n" if failure == "qml" else b"\n// changed\n"))
+ with monkeypatch.context() as broken:
+ if failure == "resolver":
+ from graphify.qt_declared_provider import DeclaredProviderIndex
+ original = DeclaredProviderIndex.provider
+ def reject(self, metadata):
+ original(self, metadata)
+ raise ValueError("QT_METADATA: public test transport corruption")
+ broken.setattr(DeclaredProviderIndex, "provider", reject)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(tmp_path, broken, operation, [path])
+ assert rejected.value.code == 1
+ else:
+ assert not _rebuild_code(tmp_path, changed_paths=[path], no_cluster=True)
+ assert before == {name: (output / name).read_bytes() for name in PRODUCTS}
+ assert cache and all(path.read_bytes() == data for path, data in cache.items())
+ path.write_bytes(valid)
+ corrected = run(tmp_path, monkeypatch, operation, [path])
+ assert normalized(corrected) == normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("shape", ["return", "child", "root"])
+def test_req_qml018_ac03_ac04_declarator_shape_edit_retires_and_repairs_api(tmp_path, monkeypatch, operation, shape):
+ """An accepted syntax edit cannot retain stale one-object API links after shape changes."""
+ corpus(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert len(facts(initial, "context_subscription")) == 2
+ path = tmp_path / ("access.cpp" if shape == "root" else "native.h")
+ original = path.read_bytes()
+ if shape == "return":
+ changed = original.replace(b"Backend *makeBackend()", b"Backend **makeBackend()")
+ elif shape == "child":
+ changed = original.replace(b"Service* service", b"Service** service").replace(b"Service *service()", b"Service **service()")
+ else:
+ changed = original.replace(b"Factory *factory", b"Factory **factory")
+ path.write_bytes(changed)
+ rejected = run(tmp_path, monkeypatch, operation, [path])
+ assert not facts(rejected, "context_subscription")
+ assert bool(facts(rejected, "context_binding")) is (shape == "child")
+ assert normalized(rejected) == normalized(clean(tmp_path, tmp_path / ".shape-rebuild"))
+ path.write_bytes(original)
+ assert normalized(run(tmp_path, monkeypatch, operation, [path])) == normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("parameter", ["backend", "handleReady"])
+def test_req_qml018_ac04_native_parameter_edit_retires_nested_subscription_and_repairs(tmp_path, monkeypatch, operation, parameter):
+ """Unchanged cached QML is rebound after C++ alone adds/removes a legacy signal parameter."""
+ corpus(tmp_path)
+ qml = tmp_path / "Main.qml"
+ qml.write_text('import QtQml\nQtObject { function handleReady() {} property Connections sub: Connections {'
+ ' target: backend.service; onReady: { backend.service.ready.connect(handleReady) } } }', encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ assert len(facts(initial, "context_subscription")) == 2
+ header = tmp_path / "native.h"
+ original = header.read_bytes()
+ header.write_bytes(original.replace(b"void ready();", ("void ready(int " + parameter + ");").encode()))
+ shadowed = run(tmp_path, monkeypatch, operation, [header])
+ assert len(facts(shadowed, "context_subscription")) == 1
+ assert len(facts(shadowed, "context_handler_endpoint")) == 1
+ assert normalized(shadowed) == normalized(clean(tmp_path, tmp_path / ".implicit-rebuild"))
+ header.write_bytes(original)
+ restored = run(tmp_path, monkeypatch, operation, [header])
+ assert normalized(restored) == normalized(initial)
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(initial)
diff --git a/tests/test_qt_update_direction_integrity.py b/tests/test_qt_update_direction_integrity.py
new file mode 100644
index 0000000000..3b8d3066b7
--- /dev/null
+++ b/tests/test_qt_update_direction_integrity.py
@@ -0,0 +1,161 @@
+"""INC-QML-34: update shortcuts and raw writers reject corrupt logical pairs."""
+from __future__ import annotations
+
+import copy
+import json
+import os
+
+import networkx as nx
+import pytest
+
+import graphify.__main__ as entrypoint
+import graphify.build as builder
+import graphify.export as export
+import graphify.watch as watch
+from graphify.cache import cache_dir
+from graphify.paths import load_node_link_graph
+from tests.test_qt_json_direction import damage
+
+
+def update(root, monkeypatch, operation, *, raw, force=False):
+ """An unchanged full refresh reaches comparison rather than the empty-batch shortcut."""
+ if operation == "manual":
+ monkeypatch.setattr(entrypoint, "_check_skill_version", lambda *_: None)
+ monkeypatch.setattr(entrypoint, "_refresh_stale_skills", lambda: None)
+ arguments = ["graphify", "update", str(root)]
+ if raw:
+ arguments.append("--no-cluster")
+ if force:
+ arguments.append("--force")
+ monkeypatch.setattr(entrypoint.sys, "argv", arguments)
+ entrypoint.main()
+ return True
+ return watch._rebuild_code(root, no_cluster=raw, force=force)
+
+
+def snapshot(root, *, pin=False):
+ output = root / "graphify-out"
+ names = ("graph.json", ".graphify_root", "manifest.json", ".qt_analysis.json", "GRAPH_REPORT.md",
+ ".graphify_labels.json", ".graphify_labels.json.sig")
+ result = {}
+ for name in names:
+ path = output / name
+ if path.is_file():
+ if pin:
+ before = path.stat()
+ os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns - 10_000_000_000))
+ result[name] = path.read_bytes(), path.stat().st_mtime_ns
+ return result
+
+
+def project(root, monkeypatch, operation, *, raw, directed=False):
+ """Actual source-backed containment and QML reads supply both producer paths."""
+ (root / "Main.qml").write_bytes(b"import QtQml\nQtObject { property int input: 1; property int output: input }")
+ (root / "keep.py").write_bytes(b"def retained(): return 7\n")
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ assert update(root, monkeypatch, operation, raw=raw)
+ if directed:
+ # Directed updates inherit this public persisted profile. Serialized
+ # endpoints are already their accepted logical pair in the real output.
+ path = root / "graphify-out/graph.json"
+ payload = json.loads(path.read_bytes())
+ payload["directed"] = True
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ assert update(root, monkeypatch, operation, raw=raw)
+ assert load_node_link_graph(root / "graphify-out/graph.json").is_directed() == directed
+
+
+def fault_actual_pair(monkeypatch, *, raw, corruption):
+ """Fault only the actual builder/deduper output; never fake extraction or validation."""
+ observed: dict = {"json_calls": 0}
+ original_json = export.to_json
+
+ def counted_json(*args, **kwargs):
+ observed["json_calls"] += 1
+ return original_json(*args, **kwargs)
+
+ monkeypatch.setattr(export, "to_json", counted_json)
+ if raw:
+ original = builder.dedupe_edges
+
+ def malformed_raw(edges):
+ result = original(edges)
+ edge = next(data for data in result if data.get("relation") == "contains")
+ damage(edge, corruption, edge["source"], edge["target"])
+ observed["before"] = copy.deepcopy(result)
+ observed["output"] = result
+ return result
+
+ monkeypatch.setattr(builder, "dedupe_edges", malformed_raw)
+ else:
+ original = builder.build_from_json
+
+ def malformed_graph(*args, **kwargs):
+ graph = original(*args, **kwargs)
+ source, target, edge = next((u, v, data) for u, v, data in graph.edges(data=True)
+ if data.get("relation") == "contains"
+ and (data.get("_src"), data.get("_tgt")) == (u, v))
+ damage(edge, corruption, source, target)
+ observed["before"] = copy.deepcopy(nx.node_link_data(graph, edges="links"))
+ observed["output"] = graph
+ return graph
+
+ monkeypatch.setattr(builder, "build_from_json", malformed_graph)
+ return observed
+
+
+def assert_rejected(root, monkeypatch, operation, *, raw, force):
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ update(root, monkeypatch, operation, raw=raw, force=force)
+ assert rejected.value.code == 1
+ else:
+ assert not update(root, monkeypatch, operation, raw=raw, force=force)
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("raw", [False, True])
+@pytest.mark.parametrize("force", [False, True])
+@pytest.mark.parametrize("corruption", ["partial_source", "partial_target", "foreign_source", "foreign_target",
+ "list", "null", "collapsed", "reversed"])
+def test_actual_corrupt_candidate_refuses_shortcut_publication_and_repairs(tmp_path, monkeypatch, capsys,
+ operation, raw, force, corruption):
+ """Force cannot grant invalid transport; the old cohort and cache survive refusal then a corrected retry."""
+ project(tmp_path, monkeypatch, operation, raw=raw, directed=corruption == "reversed")
+ prior = snapshot(tmp_path, pin=True)
+ cached = {path: path.read_bytes() for path in cache_dir(tmp_path).rglob("*.json")}
+ assert cached
+ with monkeypatch.context() as fault:
+ observed = fault_actual_pair(fault, raw=raw, corruption=corruption)
+ assert_rejected(tmp_path, monkeypatch, operation, raw=raw, force=force)
+ assert observed["json_calls"] == 0
+ unchanged = observed["output"] if raw else nx.node_link_data(observed["output"], edges="links")
+ assert unchanged == observed["before"]
+ assert snapshot(tmp_path) == prior and all(path.read_bytes() == data for path, data in cached.items())
+ diagnostic = capsys.readouterr().err
+ assert "QT_EXPORT_DIRECTION" in diagnostic
+ assert "private-needle" not in diagnostic and "forged diagnostic" not in diagnostic
+ output = tmp_path / "graphify-out"
+ assert not (output / ".graph.tmp.json").exists() and not list(output.glob(".gfy-publish-*"))
+ assert update(tmp_path, monkeypatch, operation, raw=raw, force=force)
+ assert snapshot(tmp_path) == prior
+ assert update(tmp_path, monkeypatch, operation, raw=raw, force=force)
+ assert snapshot(tmp_path) == prior
+ # A real source change remains publishable after rejection and does not
+ # become an idempotence shortcut because only run metadata was normalized.
+ source = tmp_path / "Main.qml"
+ source.write_bytes(source.read_bytes().replace(b"input", b"adjustment"))
+ assert update(tmp_path, monkeypatch, operation, raw=raw, force=force)
+ assert snapshot(tmp_path)["graph.json"][0] != prior["graph.json"][0]
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("raw", [False, True])
+def test_complete_reverse_pair_remains_accepted_on_undirected_storage(tmp_path, monkeypatch, operation, raw):
+ """A full reversal is permitted for undirected storage; only invalid pairs are refused."""
+ project(tmp_path, monkeypatch, operation, raw=raw)
+ with monkeypatch.context() as fault:
+ fault_actual_pair(fault, raw=raw, corruption="reversed")
+ assert update(tmp_path, monkeypatch, operation, raw=raw)
+ assert (tmp_path / "graphify-out/graph.json").is_file()
diff --git a/tests/test_qt_update_discovery_profile.py b/tests/test_qt_update_discovery_profile.py
new file mode 100644
index 0000000000..847798ee1f
--- /dev/null
+++ b/tests/test_qt_update_discovery_profile.py
@@ -0,0 +1,92 @@
+"""INC-QML-46: real update CLI discovery profiles and pre-publication rejection."""
+from __future__ import annotations
+
+import pytest
+
+import graphify.__main__ as entrypoint
+import graphify.watch as watch
+from tests.test_qt_final_incremental_parity import manual_update, normalized, project, published
+from tests.test_qt_product_publication import snapshot
+from tests.test_source_alias_provenance import directory_alias # noqa: F401
+from tests.test_watch_physical_coowners import cache_bytes
+
+
+def test_req_qml011_ac01_ac04_cli_default_and_optin_reach_real_publication(tmp_path, monkeypatch):
+ """The parser forwards each profile through actual nested rebuilds without changing default facts."""
+ root = tmp_path.resolve()
+ project(root)
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ observed, real_rebuild = [], watch._rebuild_code
+
+ def rebuild(*args, **kwargs):
+ observed.append(kwargs.get("follow_symlinks", False))
+ return real_rebuild(*args, **kwargs)
+
+ monkeypatch.setattr(watch, "_rebuild_code", rebuild)
+ manual_update(root, monkeypatch)
+ before, graph = snapshot(root / "graphify-out"), normalized(published(root))
+ assert observed and set(observed) == {False}
+ observed.clear()
+ manual_update(root, monkeypatch, follow_symlinks=True)
+ assert observed and set(observed) == {True}
+ assert normalized(published(root)) == graph and snapshot(root / "graphify-out") == before
+
+
+@pytest.mark.parametrize("arguments,message", [
+ (["root", "--not-supported", "--follow-symlinks"], "unknown update option"),
+ (["--follow-symlinks", "root", "--not-supported"], "unknown update option"),
+ (["root", "--follow-symlinks", "other"], "at most one path"),
+ (["--follow-symlinks", "root", "other"], "at most one path"),
+])
+def test_req_qml011_ac03_invalid_cli_profile_refuses_before_rebuild_or_publication(
+ tmp_path, monkeypatch, capsys, arguments, message):
+ """Invalid flags or multiple roots retain a real accepted cohort and cache before any rebuild."""
+ root = tmp_path.resolve()
+ project(root)
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ manual_update(root, monkeypatch)
+ output = root / "graphify-out"
+ before, cached = snapshot(output), cache_bytes(output)
+ assert cached
+
+ def forbidden(*args, **kwargs):
+ raise AssertionError("invalid parser input reached rebuild")
+
+ monkeypatch.setattr(watch, "_rebuild_code", forbidden)
+ argv = [str(root) if item == "root" else str(root / "other") if item == "other" else item
+ for item in arguments]
+ monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "update", *argv])
+ capsys.readouterr()
+ with pytest.raises(SystemExit) as rejected:
+ entrypoint.main()
+ assert rejected.value.code == 2 and message in capsys.readouterr().err
+ assert snapshot(output) == before and cache_bytes(output) == cached
+
+
+def test_req_qml011_ac01_optin_cannot_discover_foreign_qt_provider(
+ tmp_path, directory_alias, monkeypatch):
+ """Following an external alias cannot add an ambiguous native provider or alter accepted facts."""
+ root, foreign = tmp_path / "repo", tmp_path / "foreign"
+ root.mkdir()
+ foreign.mkdir()
+ project(root)
+ (foreign / "Foreign.h").write_text('class Foreign : public QObject { Q_OBJECT '
+ 'QML_NAMED_ELEMENT(Service) public: Q_INVOKABLE void stolen(); };\n', encoding="utf-8")
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ manual_update(root, monkeypatch)
+ before, graph = snapshot(root / "graphify-out"), normalized(published(root))
+ directory_alias(root / "outside", foreign)
+ manual_update(root, monkeypatch, follow_symlinks=True)
+ assert normalized(published(root)) == graph and snapshot(root / "graphify-out") == before
+ assert (foreign / "Foreign.h").exists()
+
+
+def test_req_qml011_ac01_cli_help_exposes_supported_optin(monkeypatch, capsys):
+ """Actual help makes the supported discovery option and unchanged default visible."""
+ monkeypatch.setattr(entrypoint.sys, "argv", ["graphify", "--help"])
+ entrypoint.main()
+ section = capsys.readouterr().out.split("update ", 1)[1].split("cluster-only", 1)[0]
+ assert "--follow-symlinks" in section and "default: off" in section
diff --git a/tests/test_qt_worker_cache_integrity.py b/tests/test_qt_worker_cache_integrity.py
new file mode 100644
index 0000000000..9ed471d3a6
--- /dev/null
+++ b/tests/test_qt_worker_cache_integrity.py
@@ -0,0 +1,115 @@
+"""Actual workers, parser cache retention and native scope-root publication."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+import graphify.extract as extraction
+from graphify.cache import save_cached
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.qt_analysis_state import inspect_qt_analysis
+from graphify.watch import _rebuild_code
+from tests.qml_test_helpers import canonical
+from tests.test_qt_config_incremental import _project, _snapshot, _write
+
+
+CPP = """#include
+class Backend : public QObject {
+ Q_OBJECT
+public: void tick() { emit ready(1); }
+signals: void ready(int value);
+};
+"""
+
+
+def _provider(result):
+ nodes = {node["id"]: node for node in result["nodes"]}
+ sites = [node for node in nodes.values() if node["source_file"] == "Main.qml" and
+ qml_metadata(node).get("kind") == "type_use"]
+ assert len(sites) == 1 and qml_metadata(sites[0])["status"] == "resolved"
+ links = [edge for edge in result["edges"] if edge["source"] == sites[0]["id"] and edge["relation"] == "uses"]
+ assert len(links) == 1
+ return nodes[links[0]["target"]]["source_file"]
+
+
+@pytest.mark.parametrize("roots,expected", [
+ ('["first", "second"]', "first/Public/Tools/Service.qml"),
+ ('["second", "first"]', "second/Public/Tools/Service.qml"),
+])
+def test_qml010_ac01_actual_workers_preserve_configured_roots_script_and_native_facts(tmp_path, monkeypatch, roots, expected):
+ _project(tmp_path)
+ _write(tmp_path, "Main.qml", 'import Public.Tools 1.0\nimport "helpers.js" as Tools\nService { property int displayed: Tools.compute(value) }\n')
+ _write(tmp_path, "helpers.js", ".pragma library\nfunction compute(value) { return value * 2; }\n")
+ _write(tmp_path, "backend.cpp", CPP)
+ for index in range(18):
+ _write(tmp_path, f"panels/Panel{index}.qml", f"QtObject {{ property int value: {index} }}\n")
+ paths = extraction.collect_files(tmp_path, root=tmp_path)
+ monkeypatch.setenv("GRAPHIFY_QML_IMPORT_ROOTS", roots)
+ analysis = inspect_qt_analysis(tmp_path, tmp_path / "state-output", paths)
+ ran, real_pool = [], extraction._extract_parallel
+
+ def observe_pool(*args, **kwargs):
+ succeeded = real_pool(*args, **kwargs)
+ ran.append(succeeded)
+ return succeeded
+
+ monkeypatch.setattr(extraction, "_extract_parallel", observe_pool)
+ parallel = extraction.extract(paths, root=tmp_path, cache_root=tmp_path / "pool-cache",
+ qml_import_roots=analysis.import_roots, parallel=True, max_workers=2)
+ assert ran == [True]
+ sequential = extraction.extract(list(reversed(paths)), root=tmp_path, cache_root=tmp_path / "serial-cache",
+ qml_import_roots=analysis.import_roots, parallel=False)
+ warm = extraction.extract(paths, root=tmp_path, cache_root=tmp_path / "serial-cache",
+ qml_import_roots=analysis.import_roots, parallel=False)
+ assert not parallel["qml_failures"] and not parallel["failed_sources"]
+ assert _provider(parallel) == expected
+ assert any(edge.get("context") == "qml_script_call" for edge in parallel["edges"])
+ assert any(qt_metadata(node).get("kind") == "emission" for node in parallel["nodes"])
+ assert canonical(parallel) == canonical(sequential) == canonical(warm)
+
+
+def _ast_snapshot(root):
+ base = root / "graphify-out/cache/ast"
+ return {path.relative_to(base).as_posix(): path.read_bytes() for path in base.rglob("*.json")}
+
+
+@pytest.mark.parametrize("force", [False, True])
+def test_qml012_ac02_failed_native_parser_keeps_prior_real_ast_cache_and_products(tmp_path, force):
+ source = _write(tmp_path, "backend.cpp", CPP)
+ # Model an existing production cache from before native compatibility bypass;
+ # use the real generic extractor and cache serializer, not arbitrary bytes.
+ syntax = extraction.extract_cpp(source)
+ assert syntax["nodes"]
+ save_cached(source, syntax, root=tmp_path, cache_root=tmp_path)
+ cache_before = _ast_snapshot(tmp_path)
+ assert cache_before
+ assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False)
+ assert _ast_snapshot(tmp_path) == cache_before
+ products_before = _snapshot(tmp_path)
+ source.write_text("class Backend : public QObject { Q_OBJECT public: void tick( { }", encoding="utf-8")
+ assert not _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True,
+ acquire_lock=False, force=force)
+ assert _ast_snapshot(tmp_path) == cache_before
+ assert _snapshot(tmp_path) == products_before
+ source.write_text(CPP, encoding="utf-8")
+ assert _rebuild_code(tmp_path, changed_paths=[source], no_cluster=True, acquire_lock=False)
+ assert _ast_snapshot(tmp_path) == cache_before
+
+
+@pytest.mark.parametrize("native", [True, False], ids=["qt-scoped-source", "ordinary-cpp"])
+def test_qml010_ac02_cpp_subfolder_rebase_rejects_only_native_scoped_facts(tmp_path, monkeypatch, capsys, native):
+ project = tmp_path / "project"
+ project.mkdir()
+ _write(project, "backend.cpp", CPP if native else "int add(int value) { return value + 1; }\n")
+ assert _rebuild_code(project, no_cluster=True, acquire_lock=False)
+ before = _snapshot(project)
+ monkeypatch.chdir(tmp_path)
+ succeeded = _rebuild_code(Path("project"), force=True, no_cluster=True, acquire_lock=False)
+ if native:
+ assert not succeeded
+ assert _snapshot(project) == before
+ assert "QML_ROOT_MISMATCH" in capsys.readouterr().out
+ else:
+ assert succeeded
diff --git a/tests/test_shell_portability_helpers.py b/tests/test_shell_portability_helpers.py
new file mode 100644
index 0000000000..74427bc4bf
--- /dev/null
+++ b/tests/test_shell_portability_helpers.py
@@ -0,0 +1,161 @@
+"""Characterize exact file identity at the shell/native fixture boundary."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+import pytest
+
+from tests.shell_portability import resolve_shell_path, select_shell_executable
+
+
+def test_native_path_keeps_full_identity_with_spaces(tmp_path):
+ """A full native path identifies the created file, including spaced parents."""
+ target = tmp_path / "tool with spaces" / "python"
+ target.parent.mkdir()
+ target.touch()
+ assert resolve_shell_path(str(target)) == target.resolve()
+
+
+def test_same_named_sibling_is_not_the_requested_file(tmp_path):
+ """Canonicalization must preserve parent identity rather than just basename."""
+ requested = tmp_path / "requested" / "python"
+ rejected = tmp_path / "rejected" / "python"
+ for target in (requested, rejected):
+ target.parent.mkdir()
+ target.touch()
+ assert resolve_shell_path(str(rejected)) != requested.resolve()
+
+
+def test_relative_identity_is_rejected(tmp_path, monkeypatch):
+ """An existing relative file cannot accidentally bind to the test runner cwd."""
+ (tmp_path / "python").touch()
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(AssertionError, match="relative file identity"):
+ resolve_shell_path("python")
+
+
+def test_missing_absolute_identity_is_rejected(tmp_path):
+ """A nonexistent interpreter candidate cannot satisfy a path-only comparison."""
+ with pytest.raises(FileNotFoundError):
+ resolve_shell_path(str(tmp_path / "missing-python"))
+
+
+@pytest.mark.parametrize("shell", ["bash", "sh"])
+def test_req_core004_ac03_missing_shell_selection_is_rejected(shell, tmp_path, monkeypatch):
+ """An empty or unavailable configured PATH fails before any process launch."""
+ for configured in ("", str(tmp_path)):
+ monkeypatch.setenv("PATH", configured)
+ with pytest.raises(AssertionError, match="unavailable in configured PATH"):
+ select_shell_executable(shell)
+
+
+@pytest.mark.parametrize("selection", ["relative", "directory", "missing"])
+def test_req_core004_ac03_invalid_shell_identity_is_rejected(selection, tmp_path, monkeypatch):
+ """Lookup transport cannot admit relative, directory, or nonexistent identities."""
+ target = tmp_path / "shell"
+ target.mkdir() if selection == "directory" else None
+ supplied = "shell" if selection == "relative" else str(target)
+ monkeypatch.setenv("PATH", str(tmp_path))
+ monkeypatch.setattr(shutil, "which", lambda candidate: supplied)
+ with pytest.raises(AssertionError, match="absolute executable file"):
+ select_shell_executable("bash")
+
+
+def test_req_core004_ac03_unadmitted_shell_name_is_rejected():
+ """Shell fixture admission cannot become a generic executable launcher."""
+ with pytest.raises(AssertionError, match="only Bash/sh"):
+ select_shell_executable("cmd")
+
+
+@pytest.mark.parametrize("shell", ["bash", "sh"])
+def test_req_core004_ac02_admitted_shell_runs_real_posix_command(shell):
+ """The selected absolute Bash/sh executes POSIX syntax without a GNU-only probe."""
+ if shutil.which(shell) is None:
+ pytest.skip(f"{shell} is unavailable for executable identity proof")
+ selected = select_shell_executable(shell)
+ assert Path(selected).is_absolute() and Path(selected).is_file()
+ result = subprocess.run([selected, "-c", "printf %s admitted-shell"],
+ capture_output=True, text=True, timeout=15)
+ assert result.returncode == 0, result.stderr
+ assert result.stdout == "admitted-shell"
+
+
+@pytest.mark.skipif(os.name != "nt", reason="native Windows executable search boundary")
+@pytest.mark.parametrize("shell", ["bash", "sh"])
+def test_req_core004_ac02_native_cwd_shadow_requires_explicit_path_admission(
+ shell, tmp_path, monkeypatch,
+):
+ """A real native CWD executable shadows bare launch but never an admitted PATH."""
+ selected = select_shell_executable(shell)
+ # The harmless system lookup binary is a real competing native executable;
+ # its stdout proves which process ran without launching WSL or corpus code.
+ shadow = tmp_path / f"{shell}.exe"
+ shutil.copyfile(Path(os.environ["SystemRoot"]) / "System32" / "where.exe", shadow)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("PATH", str(Path(selected).parent))
+ bare = subprocess.run([shell, shadow.name], capture_output=True, text=True, timeout=15)
+ assert bare.returncode == 0 and str(shadow) in bare.stdout
+ assert select_shell_executable(shell) == selected
+ actual = subprocess.run([select_shell_executable(shell), "-c", "printf %s admitted-shell"],
+ capture_output=True, text=True, timeout=15)
+ assert actual.returncode == 0 and actual.stdout == "admitted-shell", actual.stderr
+ # CWD is eligible only when it is itself an explicit configured PATH entry.
+ for entry in (".", ""):
+ monkeypatch.setenv("PATH", entry + os.pathsep + str(Path(selected).parent))
+ assert select_shell_executable(shell) == str(shadow.resolve())
+
+
+@pytest.mark.skipif(os.name != "nt", reason="native Windows executable search boundary")
+@pytest.mark.parametrize("consumer", ["hook_bash", "hook_sh", "skillgen_bash"])
+def test_req_core004_ac02_real_consumers_resist_native_cwd_shadow(
+ consumer, tmp_path, monkeypatch,
+):
+ """Actual hook/skill launchers use the admitted shell despite a CWD native decoy."""
+ from tests.test_hooks import _broken_uv_machine, _detect_run, _shell_verdict, _tool_venv
+ from tests.test_skillgen_input_path_injection import _run_step1
+
+ shell = "sh" if consumer == "hook_sh" else "bash"
+ selected = select_shell_executable(shell)
+ shadow = tmp_path / f"{shell}.exe"
+ shutil.copyfile(Path(os.environ["SystemRoot"]) / "System32" / "where.exe", shadow)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("PATH", str(Path(selected).parent))
+ if consumer == "hook_bash":
+ assert _shell_verdict("*", "literal-payload", tmp_path) == "REJECTED"
+ elif consumer == "hook_sh":
+ home, stubs = _broken_uv_machine(tmp_path)
+ mine = _tool_venv(home, "graphifyy", "bin/python", ok=True)
+ result = _detect_run(tmp_path, home, stubs)
+ assert result.returncode == 0 and result.stdout.startswith("RESOLVED="), result.stderr
+ assert resolve_shell_path(result.stdout.removeprefix("RESOLVED=").strip()) == mine.resolve()
+ else:
+ result = _run_step1("printf %s admitted-shell\n# INPUT_PATH\n", "literal", tmp_path)
+ assert result.returncode == 0 and result.stdout == "admitted-shell", result.stderr
+
+
+@pytest.mark.skipif(os.name != "nt" or shutil.which("sh") is None,
+ reason="native Windows and an MSYS shell required")
+def test_msys_mount_mapping_preserves_full_sibling_identity(tmp_path):
+ """Real cygpath maps /tmp and spaced parents while keeping decoys distinct."""
+ executable = select_shell_executable("sh")
+ system = subprocess.check_output([executable, "-c", "uname -s"], text=True).strip()
+ if not system.startswith(("MSYS_", "MINGW", "CYGWIN_")):
+ pytest.skip("the available Windows shell uses a different path namespace")
+ mapper = Path(executable).with_name("cygpath.exe")
+ paths = []
+ for directory in ("requested with spaces", "rejected with spaces"):
+ target = tmp_path / directory / "python"
+ target.parent.mkdir()
+ target.touch()
+ reported = subprocess.check_output(
+ [str(mapper), "-u", "--", str(target)], text=True,
+ ).strip()
+ assert reported.startswith("/"), reported
+ paths.append((target, reported))
+ requested, rejected = paths
+ assert resolve_shell_path(requested[1]) == requested[0].resolve()
+ assert resolve_shell_path(rejected[1]) == rejected[0].resolve()
+ assert resolve_shell_path(rejected[1]) != requested[0].resolve()
diff --git a/tests/test_skill_auto_refresh.py b/tests/test_skill_auto_refresh.py
index 3ac809cae3..40749178bc 100644
--- a/tests/test_skill_auto_refresh.py
+++ b/tests/test_skill_auto_refresh.py
@@ -54,15 +54,24 @@ def _stamp(dst: Path) -> str:
# What gets refreshed
# ---------------------------------------------------------------------------
-def test_every_stale_platform_is_refreshed_not_only_the_detected_one(capsys):
+@pytest.mark.parametrize("system", ["Linux", "Windows"])
+def test_every_stale_platform_is_refreshed_not_only_the_detected_one(capsys, monkeypatch, system):
+ """Refresh independent destinations; retain the ambiguous Windows shared copy."""
+ monkeypatch.setattr(mainmod.platform, "system", lambda: system)
dsts = {name: _stale(name) for name in ("claude", "codex", "opencode", "gemini")}
mainmod._refresh_stale_skills()
out, err = capsys.readouterr()
for name, dst in dsts.items():
+ if name == "gemini" and system == "Windows":
+ assert _stamp(dst) == OLD, name
+ assert dst.read_text(encoding="utf-8") == OLD_BODY, name
+ assert str(dst.parent) not in err
+ continue
assert _stamp(dst) == __version__, name
- assert dst.read_bytes() == _packaged(name), name
+ variant = "windows" if name == "claude" and system == "Windows" else name
+ assert dst.read_bytes() == _packaged(variant), name
assert str(dst.parent) in err
assert OLD in err and "GRAPHIFY_NO_AUTO_REFRESH" in err
assert out == "", "stdout must stay clean for --json and the MCP stdio server"
@@ -451,9 +460,11 @@ def test_uninstall_after_a_refresh_leaves_no_directory_behind():
assert not dst.parent.exists()
-def test_a_stale_gemini_skill_gets_the_warning_too(monkeypatch, capsys):
+@pytest.mark.parametrize("system", ["Linux", "Windows"])
+def test_a_stale_gemini_skill_gets_the_warning_too(monkeypatch, capsys, system):
"""gemini is not in _PLATFORM_CONFIG, so the version check never saw its
- ~/.gemini copy; with the refresh off it must still warn."""
+ user copy; with the refresh off it must still warn at its host destination."""
+ monkeypatch.setattr(mainmod.platform, "system", lambda: system)
monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", "1")
dst = _stale("gemini")
monkeypatch.setattr(sys, "argv", ["graphify", "--version"])
@@ -462,7 +473,10 @@ def test_a_stale_gemini_skill_gets_the_warning_too(monkeypatch, capsys):
err = capsys.readouterr().err
assert f"warning: skill at {dst.parent} is from graphify {OLD}" in err
- assert "graphify install --platform gemini" in err
+ # On Windows this is also agents' destination. The warning names the first
+ # matching installer; the stamp cannot identify the shared copy's variant.
+ platform_name = "agents" if system == "Windows" else "gemini"
+ assert f"graphify install --platform {platform_name}" in err
def test_the_first_cli_run_after_an_upgrade_refreshes_and_stays_quiet(monkeypatch, capsys):
diff --git a/tests/test_skillgen_input_path_injection.py b/tests/test_skillgen_input_path_injection.py
index 752b5d3bf1..d4797f2be8 100644
--- a/tests/test_skillgen_input_path_injection.py
+++ b/tests/test_skillgen_input_path_injection.py
@@ -23,6 +23,8 @@
import pytest
+from tests.shell_portability import resolve_shell_path, select_shell_executable
+
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILL_MD = REPO_ROOT / "graphify" / "skill.md"
@@ -56,16 +58,8 @@ def step1_script() -> str:
def _run_step1(script: str, input_path_value: str, cwd: Path) -> subprocess.CompletedProcess:
- if sys.platform == "win32":
- if len(input_path_value) > 2 and input_path_value[1] == ":":
- drive = input_path_value[0].lower()
- rest = input_path_value[2:].replace("\\", "/")
- input_path_value = f"/mnt/{drive}{rest}"
- else:
- def _conv(m: re.Match) -> str:
- clean_path = m.group(2).replace("\\", "/")
- return f"/mnt/{m.group(1).lower()}/{clean_path}"
- input_path_value = re.sub(r"([A-Za-z]):[\\/]([^\s\"'`;#|)\n]+)", _conv, input_path_value)
+ # Admit the shell explicitly; the heredoc preserves Python stdin payload bytes.
+ # Preserve the fixture's native spelling; Windows alone does not imply WSL.
substituted = script.replace("INPUT_PATH", input_path_value).replace("\r\n", "\n")
if sys.platform == "win32":
# On Windows, writing to a temp script file avoids CreateProcess quote-escaping
@@ -73,12 +67,12 @@ def _conv(m: re.Match) -> str:
script_file = cwd / "_run_step1.sh"
script_file.write_text(substituted, encoding="utf-8", newline="\n")
return subprocess.run(
- ["bash", "_run_step1.sh"],
+ [select_shell_executable("bash"), "_run_step1.sh"],
cwd=cwd, capture_output=True, text=True,
env={**os.environ, "PATH": os.environ.get("PATH", "")},
)
return subprocess.run(
- ["bash", "-c", substituted],
+ [select_shell_executable("bash"), "-c", substituted],
cwd=cwd, capture_output=True, text=True,
env={**os.environ, "PATH": os.environ.get("PATH", "")},
)
@@ -133,14 +127,21 @@ def test_step1_does_not_execute_hostile_input_path(
"""A hostile INPUT_PATH containing shell metacharacters must never execute."""
script = _extract_step1_bash_block(skill_file)
sentinel = tmp_path / f"PWNED_{attack_name}_{skill_file.replace('.', '_')}"
- malicious = payload_fn(sentinel)
+ # Relative sentinels are reachable by the actual shell on every host. A
+ # Windows backslash path can otherwise make a vulnerable control look safe.
+ malicious = payload_fn(sentinel.name)
- _run_step1(script, malicious, cwd=tmp_path)
+ result = _run_step1(script, malicious, cwd=tmp_path)
assert not sentinel.exists(), (
f"hostile {attack_name} inside substituted INPUT_PATH for {skill_file} "
f"must never execute as shell code"
)
+ assert result.returncode != 0, "a hostile nonexistent path must be rejected"
+ assert (tmp_path / "graphify-out" / ".graphify_python").is_file()
+ assert not (tmp_path / "graphify-out" / ".graphify_root").exists()
+ _run_step1("true INPUT_PATH\n", malicious, cwd=tmp_path)
+ assert sentinel.is_file(), "unsafe control must reach the same sentinel"
# --- Backwards-compatible legacy test entry points ---------------------------
@@ -150,28 +151,38 @@ def test_step1_does_not_execute_a_command_substitution_in_input_path(tmp_path: P
"""A malicious path containing $(...) must never run as shell code."""
script = _extract_step1_bash_block()
sentinel = tmp_path / "PWNED"
- malicious = f"$(touch {sentinel})"
+ malicious = f"$(touch {sentinel.name})"
- _run_step1(script, malicious, cwd=tmp_path)
+ result = _run_step1(script, malicious, cwd=tmp_path)
assert not sentinel.exists(), (
"a $(...) command substitution inside the substituted INPUT_PATH "
"must never execute"
)
+ assert result.returncode != 0, "a hostile nonexistent path must be rejected"
+ assert (tmp_path / "graphify-out" / ".graphify_python").is_file()
+ assert not (tmp_path / "graphify-out" / ".graphify_root").exists()
+ _run_step1("true INPUT_PATH\n", malicious, cwd=tmp_path)
+ assert sentinel.is_file(), "unsafe control must reach the same sentinel"
def test_step1_does_not_execute_a_semicolon_separated_command_in_input_path(tmp_path: Path):
"""A malicious path using `;` to chain a second command must never run."""
script = _extract_step1_bash_block()
sentinel = tmp_path / "PWNED2"
- malicious = f"nonexistent; touch {sentinel} #"
+ malicious = f"nonexistent; touch {sentinel.name} #"
- _run_step1(script, malicious, cwd=tmp_path)
+ result = _run_step1(script, malicious, cwd=tmp_path)
assert not sentinel.exists(), (
"a semicolon-separated command inside the substituted INPUT_PATH "
"must never execute"
)
+ assert result.returncode != 0, "a hostile nonexistent path must be rejected"
+ assert (tmp_path / "graphify-out" / ".graphify_python").is_file()
+ assert not (tmp_path / "graphify-out" / ".graphify_root").exists()
+ _run_step1("true INPUT_PATH\n", malicious, cwd=tmp_path)
+ assert sentinel.is_file(), "unsafe control must reach the same sentinel"
# --- Legitimate path handling & non-existent path failure ---------------------
@@ -186,22 +197,14 @@ def test_step1_still_resolves_a_legitimate_path(tmp_path: Path, skill_file: str)
result = _run_step1(script, str(project), cwd=tmp_path)
+ assert result.returncode == 0, result.stderr
marker = tmp_path / "graphify-out" / ".graphify_root"
assert marker.exists(), (
f"a legitimate path must still be resolved and written for {skill_file}; "
f"stdout={result.stdout!r} stderr={result.stderr!r}"
)
marker_content = marker.read_text(encoding="utf-8").strip()
- if sys.platform == "win32" and project.drive:
- drive_letter = project.drive[0].lower()
- if marker_content.startswith(f"/mnt/{drive_letter}/"):
- resolved_marker = Path(f"{drive_letter.upper()}:{marker_content[6:]}").resolve()
- elif marker_content.startswith(f"/{drive_letter}/"):
- resolved_marker = Path(f"{drive_letter.upper()}:{marker_content[2:]}").resolve()
- else:
- resolved_marker = Path(marker_content).resolve()
- else:
- resolved_marker = Path(marker_content).resolve()
+ resolved_marker = resolve_shell_path(marker_content, shell="bash")
assert resolved_marker == project.resolve()
assert marker_content.endswith("my project with spaces")
diff --git a/tests/test_source_alias_provenance.py b/tests/test_source_alias_provenance.py
new file mode 100644
index 0000000000..bca51a8ddf
--- /dev/null
+++ b/tests/test_source_alias_provenance.py
@@ -0,0 +1,290 @@
+"""REQ-QML-020: discovered source aliases remain distinct portable identities."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import subprocess
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.cache import file_hash
+import graphify.extract as extraction
+from graphify.export import to_json
+from graphify.paths import load_node_link_graph
+from graphify.watch import _rebuild_code
+from tests.test_definition_file_portability import FOO_CPP, FOO_H
+from tests.test_qt_final_incremental_parity import normalized, project, run
+from tests.test_qt_product_publication import no_scratch, snapshot
+
+
+PYTHON = b"def helper():\n return 7\n\ndef caller():\n return helper()\n"
+
+
+@pytest.fixture
+def directory_alias(request):
+ """Own real directory links; Windows junctions need no elevated token."""
+ if os.name != "nt":
+ request.getfixturevalue("requires_symlinks")
+ aliases = []
+
+ def create(link, target):
+ if os.name == "nt":
+ result = subprocess.run(["cmd", "/d", "/c", "mklink", "/J", str(link), str(target)],
+ capture_output=True, text=True, check=False)
+ assert result.returncode == 0, "Temporary NTFS directory junction creation failed"
+ else:
+ link.symlink_to(target, target_is_directory=True)
+ aliases.append(link)
+ assert link.resolve() == target.resolve()
+ return link
+
+ yield create
+ # Remove only owned link objects. Their target fixture tree stays intact.
+ for link in reversed(aliases):
+ if os.name == "nt":
+ link.rmdir()
+ else:
+ link.unlink()
+
+
+def extracted(paths, root, cache):
+ result = extraction.extract(paths, root=root, cache_root=cache, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ return result, build_from_json(result, root=root)
+
+
+@pytest.mark.parametrize("aliased_root", [False, True])
+def test_req_qml020_ac01_discovered_directory_sources_survive_cold_warm_and_reload(
+ tmp_path, monkeypatch, directory_alias, aliased_root):
+ """A followed directory and its target keep separate nodes, calls and cache keys."""
+ root = tmp_path / "repo"
+ (root / "real").mkdir(parents=True)
+ (root / "real/member.py").write_bytes(PYTHON)
+ directory_alias(root / "linked", root / "real")
+ scan = directory_alias(tmp_path / "scan", root) if aliased_root else root
+ paths = extraction.collect_files(scan, follow_symlinks=True, root=scan)
+ assert {path.relative_to(scan).as_posix() for path in paths} == {
+ "real/member.py", "linked/member.py"}
+ assert file_hash(scan / "real/member.py", root) != file_hash(scan / "linked/member.py", root)
+ cold, cold_graph = extracted(paths, scan, tmp_path / "cache")
+ identities = {source: {node["id"] for node in cold["nodes"] if node.get("source_file") == source}
+ for source in ("real/member.py", "linked/member.py")}
+ assert all(identities.values()) and identities["real/member.py"].isdisjoint(identities["linked/member.py"])
+ for source, owned in identities.items():
+ prefix = source.removesuffix(".py").replace("/", "_")
+ assert owned == {prefix, f"{prefix}_helper", f"{prefix}_caller"}
+ calls = [edge for edge in cold["edges"] if edge.get("source_file") == source
+ and edge.get("context") == "call"]
+ assert calls and all(edge["source"] in owned and edge["target"] in owned for edge in calls)
+
+ def uncached(*_args):
+ pytest.fail("An unchanged source must use its own cached fragment")
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", uncached)
+ warm, warm_graph = extracted(paths, scan, tmp_path / "cache")
+ assert normalized(warm_graph) == normalized(cold_graph)
+ for directed in (False, True):
+ graph = build_from_json(warm, root=scan, directed=directed)
+ if directed:
+ assert all(graph.has_edge(f"{name}_member_caller", f"{name}_member_helper")
+ for name in ("real", "linked"))
+ output = tmp_path / f"round-trip-{directed}.json"
+ assert to_json(graph, {}, str(output), force=True)
+ reloaded = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert normalized(reloaded) == normalized(graph)
+
+
+def test_req_qml020_ac01_leaf_alias_retains_its_source_below_an_aliased_root(
+ tmp_path, requires_symlinks, directory_alias, monkeypatch):
+ """A symlink file remains distinct when the entire scan root is also an alias."""
+ root = tmp_path / "repo"
+ (root / "sub").mkdir(parents=True)
+ target = root / "real.py"
+ target.write_bytes(PYTHON)
+ (root / "sub/link.py").symlink_to(target)
+ scan = directory_alias(tmp_path / "scan", root)
+ paths = extraction.collect_files(scan)
+ cold, before = extracted(paths, scan, tmp_path / "cache")
+ assert {node.get("source_file") for node in cold["nodes"]} == {"real.py", "sub/link.py"}
+
+ def uncached(*_args):
+ pytest.fail("Leaf alias and target fragments must both survive warm replay")
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", uncached)
+ _, after = extracted(paths, scan, tmp_path / "cache")
+ assert normalized(after) == normalized(before)
+
+
+@pytest.mark.parametrize("fault", [OSError, RuntimeError])
+def test_req_qml020_ac02_failed_parent_anchor_keeps_only_accepted_physical_identity(
+ tmp_path, directory_alias, monkeypatch, capsys, fault):
+ """An inaccessible alias anchor falls back to a verified in-root source safely."""
+ root = tmp_path / "repo"
+ root.mkdir()
+ (root / "member.py").write_bytes(PYTHON)
+ scan = directory_alias(tmp_path / "scan", root)
+ _, expected = extracted([root / "member.py"], root, tmp_path / "canonical-cache")
+ realpath, observed = extraction._cached_realpath, []
+ body = "private backend body must not become source context"
+
+ def inaccessible_parent(path, cwd):
+ if Path(path) == scan:
+ observed.append(path)
+ raise fault(body)
+ return realpath(path, cwd)
+
+ # Forward the real cache owner's invalidation; the adapter only injects
+ # one ancestor lookup failure after accepted source facts are produced.
+ setattr(inaccessible_parent, "cache_clear", realpath.cache_clear)
+ monkeypatch.setattr(extraction, "_cached_realpath", inaccessible_parent)
+ for _ in range(2):
+ result, actual = extracted([scan / "member.py"], root, tmp_path / "alias-cache")
+ assert normalized(actual) == normalized(expected)
+ assert {node["source_file"] for node in result["nodes"]} == {"member.py"}
+ assert body not in json.dumps(result)
+ assert observed and body not in capsys.readouterr().out
+
+
+def test_req_qml020_ac01_native_definition_keeps_discovered_directory_provenance(
+ tmp_path, directory_alias):
+ """Actual C++ declaration/definition merging keeps the walked source fields."""
+ root = tmp_path / "repo"
+ (root / "real").mkdir(parents=True)
+ (root / "real/Foo.h").write_text(FOO_H, encoding="utf-8")
+ (root / "real/Foo.cpp").write_text(FOO_CPP, encoding="utf-8")
+ directory_alias(root / "linked", root / "real")
+ paths = [root / "linked/Foo.h", root / "linked/Foo.cpp"]
+ graphs = []
+ for _ in range(2):
+ result, graph = extracted(paths, root, tmp_path / "cache")
+ definitions = [node for node in result["nodes"] if node.get("definition_file")]
+ assert definitions and all(node["source_file"] == "linked/Foo.h"
+ and node["definition_file"] == "linked/Foo.cpp" for node in definitions)
+ assert all(item["source_file"].startswith("linked/")
+ for item in result["nodes"] + result["edges"] if item.get("source_file"))
+ graphs.append(graph)
+ assert normalized(graphs[0]) == normalized(graphs[1])
+
+
+def test_req_qml020_ac02_foreign_alias_cannot_acquire_native_root_authority(
+ tmp_path, directory_alias):
+ """A lexically local link to a foreign QObject remains excluded and diagnostic."""
+ root, foreign = tmp_path / "repo", tmp_path / "foreign"
+ root.mkdir()
+ foreign.mkdir()
+ target = foreign / "Foreign.h"
+ target.write_bytes(b"class Foreign : public QObject { Q_OBJECT public: Q_INVOKABLE void run(); };\n")
+ directory_alias(root / "linked", foreign)
+ assert extraction.collect_files(root, follow_symlinks=True, root=root) == []
+ before = target.read_bytes()
+ result = extraction.extract([root / "linked/Foreign.h"], root=root,
+ cache_root=tmp_path / "cache", parallel=False)
+ assert result["failed_sources"] == [str(root / "linked/Foreign.h")]
+ assert result["qml_failures"] == [{"code": "QT_CPP_ROOT", "source_file": ""}]
+ assert not [node for node in result["nodes"] if node.get("metadata", {}).get("qt")]
+ assert target.read_bytes() == before
+
+
+def test_req_qml020_ac03_watch_rename_deletion_and_repeat_preserve_discovered_source(
+ tmp_path, directory_alias):
+ """Real graph publication tracks two renames, deletion and no-change replay."""
+ root = tmp_path / "repo"
+ (root / "real").mkdir(parents=True)
+ (root / ".graphifyignore").write_text("real/\n", encoding="utf-8")
+ current = root / "real/old.py"
+ current.write_bytes(PYTHON)
+ directory_alias(root / "linked", root / "real")
+ output = root / "graphify-out/graph.json"
+ assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ for name in ("first.py", "second.py"):
+ destination = current.with_name(name)
+ current.rename(destination)
+ current = destination
+ assert _rebuild_code(root, changed_paths=[Path("linked") / name], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ actual = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert {data.get("source_file") for _, data in actual.nodes(data=True)} == {f"linked/{name}"}
+ paths = extraction.collect_files(root, follow_symlinks=True, root=root)
+ _, full = extracted(paths, root, tmp_path / f"full-{name}")
+ assert normalized(actual) == normalized(full)
+ accepted = output.read_bytes()
+ assert _rebuild_code(root, changed_paths=[], follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ assert output.read_bytes() == accepted
+ current.unlink()
+ assert _rebuild_code(root, changed_paths=[Path("linked/second.py")], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ retired = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert retired.number_of_nodes() == retired.number_of_edges() == 0
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml020_ac03_policy19_products_refresh_retain_and_recover(
+ tmp_path, directory_alias, monkeypatch, operation):
+ """An unchanged Qt cohort refreshes aliases, with real failed publication retention."""
+ import graphify.qt_analysis_state as state
+ import graphify.qt_incremental as policy
+
+ root = tmp_path / "repo"
+ root.mkdir()
+ project(root)
+ (root / "real").mkdir()
+ (root / "real/member.py").write_bytes(PYTHON)
+ directory_alias(root / "linked", root / "real")
+ (root / ".graphifyignore").write_text("real/\n", encoding="utf-8")
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ with monkeypatch.context() as prior:
+ prior.setattr(policy, "QT_POLICY_VERSION", 19)
+ run(root, prior, operation, follow_symlinks=True)
+ output = root / "graphify-out"
+ before, old_stamp = snapshot(output), state.read_qt_fingerprint(output)
+ observed, real_extract = [], extraction.extract
+
+ def record_inputs(paths, *args, **kwargs):
+ paths = list(paths)
+ observed.append({path.relative_to(root).as_posix() for path in paths})
+ return real_extract(paths, *args, **kwargs)
+
+ monkeypatch.setattr(extraction, "extract", record_inputs)
+ real_commit = state.commit_qt_analysis
+
+ def fail_after_staging(*args, **kwargs):
+ real_commit(*args, **kwargs)
+ raise OSError("public source identity refresh publication failure")
+
+ with monkeypatch.context() as failed:
+ failed.setattr(state, "commit_qt_analysis", fail_after_staging)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejection:
+ run(root, failed, operation, [], follow_symlinks=True)
+ assert rejection.value.code == 1
+ else:
+ assert not _rebuild_code(root, changed_paths=[], no_cluster=True, follow_symlinks=True)
+ assert snapshot(output) == before and state.read_qt_fingerprint(output) == old_stamp
+ no_scratch(output)
+ repaired = run(root, monkeypatch, operation, [], follow_symlinks=True)
+ assert state.read_qt_fingerprint(output) != old_stamp
+ assert observed and {"Main.qml", "backend.h", "loader.cpp"} <= set.union(*observed)
+ assert "linked/member.py" in {data.get("source_file") for _, data in repaired.nodes(data=True)}
+ paths = extraction.collect_files(root, root=root, follow_symlinks=True)
+ _, full = extracted(paths, root, tmp_path / "full")
+ assert normalized(repaired) == normalized(full)
+ accepted = snapshot(output)
+ assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=True)) == normalized(repaired)
+ assert snapshot(output) == accepted
+ no_scratch(output)
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX directory-symlink discovery profile")
+def test_req_qml020_ac03_directory_symlink_requires_explicit_discovery_profile(tmp_path, directory_alias):
+ """A real alias remains absent by default; only supported opt-in admits it."""
+ root = tmp_path.resolve()
+ real = root / "real"
+ real.mkdir()
+ (real / "member.py").write_bytes(PYTHON)
+ directory_alias(root / "linked", real)
+ (root / ".graphifyignore").write_text("real/\n", encoding="utf-8")
+ assert extraction.collect_files(root, root=root) == []
+ assert extraction.collect_files(root, root=root, follow_symlinks=True) == [root / "linked/member.py"]
diff --git a/tests/test_source_identity_remap_owners.py b/tests/test_source_identity_remap_owners.py
new file mode 100644
index 0000000000..4a0932a568
--- /dev/null
+++ b/tests/test_source_identity_remap_owners.py
@@ -0,0 +1,94 @@
+"""INC-QML-44/45: shared resolved IDs cannot replace walked source owners."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+import graphify.extract as extraction
+from graphify.export import to_json
+from graphify.paths import load_node_link_graph
+from tests.test_qt_final_incremental_parity import normalized
+from tests.test_source_alias_provenance import directory_alias # noqa: F401
+
+
+SOURCE = b"def helper():\n return 7\n\ndef caller():\n return helper()\n"
+
+
+def fixture(root, directory_alias):
+ """Only actual discovered aliases share a physical source; no source is copied."""
+ real = root / "real"
+ real.mkdir()
+ source = real / "member.py"
+ source.write_bytes(SOURCE)
+ first = directory_alias(root / "first", real) / source.name
+ second = directory_alias(root / "second", real) / source.name
+ return source, first, second
+
+
+def graph(paths, root, cache):
+ result = extraction.extract(paths, root=root, cache_root=cache, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ return build_from_json(result, root=root, directed=True), result
+
+
+def assert_owners(result, names):
+ """File nodes and actual calls retain each owner's complete disjoint identity set."""
+ for name in names:
+ owned = {node["id"] for node in result["nodes"] if node.get("source_file") == name}
+ prefix = name.removesuffix(".py").replace("/", "_")
+ assert owned == {prefix, prefix + "_helper", prefix + "_caller"}
+ calls = [edge for edge in result["edges"] if edge.get("source_file") == name
+ and edge.get("context") == "call"]
+ assert calls and all(edge["source"] in owned and edge["target"] in owned for edge in calls)
+
+
+@pytest.mark.parametrize("canonical_first", [True, False])
+@pytest.mark.parametrize("relative", [True, False])
+def test_req_qml020_ac02_primary_physical_owner_survives_both_batch_orders(
+ tmp_path, directory_alias, monkeypatch, canonical_first, relative):
+ """An appended alias cannot steal a supplied physical owner, even with relative inputs."""
+ root = tmp_path.resolve()
+ source, first, second = fixture(root, directory_alias)
+ expected, _ = graph([source, first, second], root, root / "canonical-cache")
+ paths = [source, first, second] if canonical_first else [first, second, source]
+ monkeypatch.chdir(root)
+ if relative:
+ paths = [path.relative_to(root) for path in paths]
+ actual, result = graph(paths, root, root / "ordered-cache")
+ assert_owners(result, ["real/member.py", "first/member.py", "second/member.py"])
+ assert normalized(actual) == normalized(expected)
+ warm, _ = graph(paths, root, root / "ordered-cache")
+ assert normalized(warm) == normalized(expected)
+ output = root / "reloaded.json"
+ assert to_json(warm, {}, str(output), force=True)
+ assert normalized(load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))) == normalized(expected)
+ assert source.read_bytes() == SOURCE
+
+
+@pytest.mark.parametrize("reverse", [False, True])
+def test_req_qml020_ac02_multiple_aliases_cannot_authorize_arbitrary_physical_target(
+ tmp_path, directory_alias, reverse):
+ """A shared import keeps its physical endpoint without inventing an accepted owner."""
+ root = tmp_path.resolve()
+ source, first, second = fixture(root, directory_alias)
+ consumer = root / "consumer.py"
+ consumer.write_bytes(b"import real.member\n\ndef use():\n return real.member.helper()\n")
+ paths = [consumer, second, first] if reverse else [consumer, first, second]
+ actual, result = graph(paths, root, root / "cache")
+ assert_owners(result, ["first/member.py", "second/member.py"])
+ assert not {"real_member", "real_member_helper", "real_member_caller"} & {
+ node["id"] for node in result["nodes"]}
+ # Graph assembly retains its existing placeholder for a dangling import;
+ # that placeholder carries no accepted source/declaration authority.
+ assert actual.nodes["real_member"].get("source_file") != "real/member.py"
+ imports = [edge for edge in result["edges"] if edge.get("relation") == "imports"
+ and edge.get("source_file") == "consumer.py"]
+ assert imports and all(edge["target"] == "real_member" for edge in imports)
+ assert all(edge["target"] not in {"first_member", "second_member"} for edge in imports)
+ assert not [edge for edge in result["edges"] if edge.get("source_file") == "consumer.py"
+ and edge.get("context") == "call"]
+ warm, _ = graph(paths, root, root / "cache")
+ assert normalized(warm) == normalized(actual)
+ assert source.read_bytes() == SOURCE
diff --git a/tests/test_source_input_admission.py b/tests/test_source_input_admission.py
new file mode 100644
index 0000000000..903540e504
--- /dev/null
+++ b/tests/test_source_input_admission.py
@@ -0,0 +1,191 @@
+"""INC-QML-45: native filename admission refuses unsafe partial extraction."""
+from __future__ import annotations
+
+import ctypes
+import json
+import os
+from pathlib import Path
+
+import pytest
+
+import graphify.extract as extraction
+import graphify.source_identity as identity
+import graphify.watch as watch
+from tests.test_qt_final_incremental_parity import clean, normalized, project, run
+from tests.test_qt_product_publication import no_scratch, snapshot
+
+
+pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows native filename admission")
+
+
+def caches(output):
+ """Retain exact pre-admission cache bytes alongside the accepted product cohort."""
+ return {path.relative_to(output).as_posix(): path.read_bytes()
+ for path in (output / "cache").rglob("*") if path.is_file()}
+
+
+def reject_native_api(monkeypatch, source, alternative, fault):
+ """Isolate only GetLongPathNameW; filesystem checks and callers remain real."""
+ original = ctypes.windll.kernel32.GetLongPathNameW
+ observed = []
+
+ def reject(path, buffer, capacity):
+ if Path(path).name != source.name:
+ return original(path, buffer, capacity)
+ observed.append(path)
+ if fault == "backend":
+ raise PermissionError("synthetic-private-native-body\ncredentials=redact")
+ if fault == "zero":
+ return 0
+ if fault == "oversize":
+ return capacity
+ if fault == "empty":
+ return 1
+ if fault == "length":
+ buffer.value = str(source)
+ return 1
+ buffer.value = str(alternative)
+ return len(buffer.value.encode("utf-16-le")) // 2
+
+ monkeypatch.setattr(ctypes.windll.kernel32, "GetLongPathNameW", reject)
+ return observed
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+@pytest.mark.parametrize("fault", ["zero", "oversize", "empty", "length", "target", "backend"])
+def test_req_qml012_ac01_ac02_qml020_ac01_native_failure_retains_repairs_and_repeats(
+ tmp_path, monkeypatch, capsys, operation, fault):
+ """Admission failure precedes workers/cache writes; actual updates recover and repeat."""
+ root = tmp_path.resolve()
+ source = project(root)
+ alternative = root / "keep.py"
+ monkeypatch.chdir(root)
+ initial = run(root, monkeypatch, operation)
+ output = root / "graphify-out"
+ before, cached = snapshot(output), caches(output)
+ assert cached
+ source.write_bytes(source.read_bytes().replace(b"adjustment", b"offset"))
+ capsys.readouterr()
+ with monkeypatch.context() as failed:
+ observed = reject_native_api(failed, source, alternative, fault)
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(root, monkeypatch, operation, [source])
+ assert rejected.value.code == 1
+ else:
+ assert not watch._rebuild_code(root, changed_paths=[source], no_cluster=True)
+ assert observed and snapshot(output) == before and caches(output) == cached
+ captured = capsys.readouterr()
+ text = captured.out + captured.err
+ assert "SOURCE_INPUT_IDENTITY_FAILED" in text
+ assert f"source={json.dumps(source.relative_to(root).as_posix())};" in text
+ assert "synthetic-private-native-body" not in text and "credentials=redact" not in text
+ assert str(root) not in text.split("Rebuild failed:")[-1]
+ no_scratch(output)
+ repaired = run(root, monkeypatch, operation, [source])
+ assert normalized(repaired) != normalized(initial)
+ assert normalized(repaired) == normalized(clean(root, root / "clean-cache"))
+ accepted = snapshot(output)
+ run(root, monkeypatch, operation, [])
+ assert snapshot(output) == accepted
+ no_scratch(output)
+
+
+@pytest.mark.parametrize("shape", ["missing", "directory", "child_of_file"])
+def test_req_qml020_ac01_missing_and_nonfile_keep_existing_admission_shape(tmp_path, monkeypatch, shape):
+ """Legacy missing/non-file inputs bypass filename expansion and keep their path."""
+ source = tmp_path / "existing.py"
+ source.write_bytes(b"def existing():\n return 1\n")
+ path = {"missing": tmp_path / "missing.py", "directory": tmp_path,
+ "child_of_file": source / "child.py"}[shape]
+
+ def forbidden(*args):
+ raise AssertionError("native expansion must not run")
+
+ monkeypatch.setattr(ctypes.windll.kernel32, "GetLongPathNameW", forbidden)
+ assert identity.normalize_input_source(path, tmp_path) == path
+
+
+@pytest.mark.parametrize("label", ["a;delimiter.py", "a\u2028.py", "x" * 157 + ".py", "x" * 158 + ".py"])
+def test_req_qml012_ac04_native_context_is_quoted_bounded_and_lexical(tmp_path, monkeypatch, label):
+ """Diagnostic context rejects control/oversized text without exposing backend bodies."""
+ source = tmp_path / label
+ source.write_bytes(b"pass\n")
+ reject_native_api(monkeypatch, source, source, "backend")
+ with pytest.raises(ValueError) as rejected:
+ identity.normalize_input_source(source, tmp_path)
+ context = label if len(label) <= 160 and "\u2028" not in label else ""
+ assert str(rejected.value) == (f'SOURCE_INPUT_IDENTITY_FAILED: source={json.dumps(context)}; native source '
+ 'spelling unavailable; repair filesystem access and retry')
+ assert identity._source_context(source, None) == ""
+
+
+@pytest.mark.parametrize("fault", [OSError, RuntimeError])
+def test_req_qml012_ac04_failed_context_lookup_keeps_bounded_admission_error(tmp_path, monkeypatch, fault):
+ """A failed CWD/lexical adapter cannot replace the actual native rejection diagnostic."""
+ source = tmp_path / "source.py"
+ source.write_bytes(b"pass\n")
+ reject_native_api(monkeypatch, source, source, "backend")
+ original = os.path.abspath
+
+ def unavailable(path):
+ if Path(path) == source:
+ raise fault("synthetic-private-cwd-body")
+ return original(path)
+
+ monkeypatch.setattr(identity.os.path, "abspath", unavailable)
+ with pytest.raises(ValueError) as rejected:
+ identity.normalize_input_source(source, tmp_path)
+ assert str(rejected.value) == ('SOURCE_INPUT_IDENTITY_FAILED: source=""; native source '
+ 'spelling unavailable; repair filesystem access and retry')
+
+
+def test_req_qml012_ac01_native_admission_refuses_before_any_worker_or_cache_write(tmp_path, monkeypatch):
+ """Direct extraction reports refusal without claiming writer retention or doing work."""
+ source = tmp_path / "source.py"
+ source.write_bytes(b"def original():\n return 1\n")
+ cache = tmp_path / "cache-owner"
+ extraction.extract([source], root=tmp_path, cache_root=cache, parallel=False)
+ before = {path.relative_to(cache): path.read_bytes() for path in cache.rglob("*") if path.is_file()}
+ assert before
+ source.write_bytes(b"def changed():\n return 2\n")
+ reject_native_api(monkeypatch, source, source, "zero")
+
+ def forbidden(*args):
+ raise AssertionError("worker must not run")
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", forbidden)
+ with pytest.raises(ValueError, match="SOURCE_INPUT_IDENTITY_FAILED") as rejected:
+ extraction.extract([source], root=tmp_path, cache_root=cache, parallel=False)
+ assert "retained" not in str(rejected.value)
+ assert before == {path.relative_to(cache): path.read_bytes() for path in cache.rglob("*") if path.is_file()}
+
+
+@pytest.mark.parametrize("operation,fault", [("stat", OSError), ("stat", RuntimeError),
+ ("samefile", None)])
+def test_req_qml012_ac01_native_filesystem_identity_failure_is_bounded(tmp_path, monkeypatch, operation, fault):
+ """A stat failure or changed entry proof cannot silently admit an existing file."""
+ source = tmp_path / "source.py"
+ source.write_bytes(b"pass\n")
+ original = getattr(Path, operation)
+
+ def unavailable(path, *args, **kwargs):
+ if path == source:
+ if fault is not None:
+ raise fault("synthetic-private-filesystem-body")
+ return False
+ return original(path, *args, **kwargs)
+
+ monkeypatch.setattr(Path, operation, unavailable)
+ with pytest.raises(ValueError) as rejected:
+ identity.normalize_input_source(source, tmp_path)
+ assert str(rejected.value) == ('SOURCE_INPUT_IDENTITY_FAILED: source="source.py"; native source '
+ 'spelling unavailable; repair filesystem access and retry')
+
+
+def test_req_qml020_ac01_nonwindows_input_needs_no_native_lookup(tmp_path, monkeypatch):
+ """The platform guard retains POSIX source handling without a Windows dependency."""
+ source = tmp_path / "source.py"
+ with monkeypatch.context() as other_platform:
+ other_platform.setattr(identity.os, "name", "posix")
+ assert identity.normalize_input_source(source, tmp_path) is source
diff --git a/tests/test_terraform_modules.py b/tests/test_terraform_modules.py
index 8e996fbb1e..a46546f5cb 100644
--- a/tests/test_terraform_modules.py
+++ b/tests/test_terraform_modules.py
@@ -66,19 +66,39 @@ def test_environment_application_base_topology_and_source_provenance(tmp_path):
assert len(_module_edges(result)) == 2
-def test_same_named_directories_and_cross_file_references_stay_separate(tmp_path):
+@pytest.mark.parametrize("line_ending", ["\n", "\r\n"])
+def test_same_named_directories_and_cross_file_references_stay_separate(tmp_path, line_ending):
+ """Portable provenance and scoped references survive original UTF-8 bytes and JSON."""
for directory in ("dev/app", "prod/app", "a-b/app", "a_b/app"):
- _write(tmp_path, f"{directory}/main.tf", 'variable "name" {}\n')
- _write(tmp_path, f"{directory}/use.tf", 'output "name" { value = var.name }\n')
+ for file, body in (("main.tf", 'variable "name" {}'),
+ ("use.tf", 'output "name" { value = var.name }')):
+ # Write exact bytes rather than letting the host translate CRLF.
+ source = f"# café{line_ending}{body}{line_ending}".encode("utf-8")
+ _write(tmp_path, f"{directory}/{file}", "").write_bytes(source)
result = _extract(tmp_path)
_assert_integrity(result)
variables = [n for n in result["nodes"] if n["label"] == "var.name"]
assert len({n["id"] for n in variables}) == 4
+ references = [e for e in result["edges"] if e["relation"] == "references"]
+ assert len(references) == 4
+ # Facade source paths are POSIX on every host. Native str(Path(...)) uses
+ # backslashes on Windows and would reject correct portable graph facts.
+ from graphify.export import to_json
+ from graphify.paths import load_node_link_graph
+ graph_path = tmp_path / "roundtrip.json"
+ to_json(build_from_json(result, root=tmp_path, directed=True), {}, str(graph_path), force=True)
+ restored = load_node_link_graph(json.loads(graph_path.read_text(encoding="utf-8")))
+ assert restored.is_directed()
for variable in variables:
- source = str(Path(variable["source_file"]).parent / "use.tf")
+ source = (Path(variable["source_file"]).parent / "use.tf").as_posix()
output = _node(result, "output.name", source)
- assert any(e["source"] == output["id"] and e["target"] == variable["id"]
- for e in result["edges"])
+ edge = next(e for e in references if e["source"] == output["id"])
+ assert edge["target"] == variable["id"]
+ assert edge["source_file"] == source
+ assert edge["source_location"] == output["source_location"] == "L2"
+ assert edge["confidence"] == "EXTRACTED"
+ assert restored.has_edge(output["id"], variable["id"])
+ assert restored.edges[output["id"], variable["id"]]["source_file"] == source
def test_raw_extractor_scopes_ids_by_full_directory(tmp_path):
diff --git a/tests/test_uninstall_scope.py b/tests/test_uninstall_scope.py
index 9102cdfabd..ca169905ce 100644
--- a/tests/test_uninstall_scope.py
+++ b/tests/test_uninstall_scope.py
@@ -13,6 +13,7 @@
from __future__ import annotations
from pathlib import Path
+import platform as host_platform
import pytest
@@ -30,6 +31,19 @@
]
+@pytest.fixture(autouse=True, params=["Linux", "Windows"])
+def install_host_profile(monkeypatch, request):
+ """Exercise both destination contracts independently of the physical host."""
+ monkeypatch.setattr(host_platform, "system", lambda: request.param)
+
+
+def _plant_user_skill_tree(platform: str, dot_dir: str) -> Path:
+ """Plant Gemini's Windows global copy in .agents; project copies stay .gemini."""
+ if platform == "gemini" and host_platform.system() == "Windows":
+ dot_dir = ".agents"
+ return _plant_skill_tree(Path.home(), dot_dir)
+
+
def _plant_skill_tree(root: Path, dot_dir: str) -> Path:
"""Create //skills/graphify/{SKILL.md, references/x.md, .graphify_version}."""
skill_dir = root / dot_dir / "skills" / "graphify"
@@ -43,7 +57,7 @@ def _plant_skill_tree(root: Path, dot_dir: str) -> Path:
@pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS)
def test_project_dir_call_never_touches_global(uninstall_fn, platform, dot_dir, tmp_path):
"""fn(project_dir) removes only the project skill tree (#2215 trap closed)."""
- global_tree = _plant_skill_tree(Path.home(), dot_dir)
+ global_tree = _plant_user_skill_tree(platform, dot_dir)
proj_dir = tmp_path / "proj"
project_tree = _plant_skill_tree(proj_dir, dot_dir)
@@ -59,7 +73,7 @@ def test_project_dir_call_never_touches_global(uninstall_fn, platform, dot_dir,
@pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS)
def test_bare_call_still_removes_global(uninstall_fn, platform, dot_dir, tmp_path, monkeypatch):
"""fn() with no args keeps the historical CLI behavior: global skill removed."""
- global_tree = _plant_skill_tree(Path.home(), dot_dir)
+ global_tree = _plant_user_skill_tree(platform, dot_dir)
cwd = tmp_path / "empty-cwd"
cwd.mkdir()
monkeypatch.chdir(cwd)
@@ -73,7 +87,7 @@ def test_bare_call_still_removes_global(uninstall_fn, platform, dot_dir, tmp_pat
@pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS)
def test_remove_user_skill_opt_in_with_project_dir(uninstall_fn, platform, dot_dir, tmp_path):
"""fn(pd, remove_user_skill=True) removes the global skill, leaves the project tree."""
- global_tree = _plant_skill_tree(Path.home(), dot_dir)
+ global_tree = _plant_user_skill_tree(platform, dot_dir)
proj_dir = tmp_path / "proj"
project_tree = _plant_skill_tree(proj_dir, dot_dir)
@@ -88,7 +102,7 @@ def test_remove_user_skill_opt_in_with_project_dir(uninstall_fn, platform, dot_d
@pytest.mark.parametrize("uninstall_fn,platform,dot_dir", PLATFORMS)
def test_project_true_removes_only_project_tree(uninstall_fn, platform, dot_dir, tmp_path):
"""fn(pd, project=True) removes only the project skill tree."""
- global_tree = _plant_skill_tree(Path.home(), dot_dir)
+ global_tree = _plant_user_skill_tree(platform, dot_dir)
proj_dir = tmp_path / "proj"
project_tree = _plant_skill_tree(proj_dir, dot_dir)
diff --git a/tests/test_upstream_qt_cache_integration.py b/tests/test_upstream_qt_cache_integration.py
new file mode 100644
index 0000000000..c287e69bb4
--- /dev/null
+++ b/tests/test_upstream_qt_cache_integration.py
@@ -0,0 +1,194 @@
+"""INC-QML-49: combined Qt/Python cache producers preserve facts and ownership."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+import graphify.cache as cache
+import graphify.extract as extraction
+import graphify.qt_analysis_state as qt_state
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from graphify.watch import _rebuild_code
+from tests.test_qt_final_incremental_parity import normalized, project, published
+from tests.test_qt_product_publication import no_scratch, snapshot
+
+
+@pytest.fixture
+def integrated_versions(monkeypatch):
+ """Isolate installed metadata; the combined release owns the actual schema."""
+ real_version = qt_state.version
+
+ def current_version(package):
+ return "0.9.76" if package == "graphifyy" else real_version(package)
+
+ monkeypatch.setattr(cache, "_EXTRACTOR_VERSION", "0.9.76")
+ monkeypatch.setattr(cache, "_cleaned_ast_dirs", set())
+ # Each case owns a fresh project. Keep process bookkeeping isolated, then
+ # complete its real stat-index flush before fixture restoration.
+ for name, value in (("_stat_index", {}), ("_stat_index_root", None),
+ ("_stat_index_anchor", None), ("_stat_index_path", None),
+ ("_stat_index_dirty", False)):
+ monkeypatch.setattr(cache, name, value, raising=False)
+ monkeypatch.setattr(qt_state, "version", current_version)
+ yield real_version
+ cache._flush_stat_index()
+
+
+def semantic_entry(root):
+ """Seed a real prompt-owned entry whose bytes AST migration must not touch."""
+ source = root / "notes.md"
+ source.write_text("Public cache integration note.\n", encoding="utf-8")
+ prompt = "Public semantic extraction contract"
+ cache.save_cached(source, {
+ "nodes": [{"id": "public-note", "label": "note", "source_file": str(source)}],
+ "edges": [],
+ }, root=root, kind="semantic", prompt=prompt)
+ directory = cache.cache_dir(root, "semantic", cache.prompt_fingerprint(prompt))
+ entry = directory / f"{cache.file_hash(source, root)}.json"
+ return source, prompt, entry, entry.read_bytes()
+
+
+def assert_semantic_preserved(root, state):
+ source, prompt, entry, before = state
+ loaded = cache.load_cached(source, root=root, kind="semantic", prompt=prompt)
+ assert loaded is not None and loaded["nodes"][0]["id"] == "public-note"
+ assert entry.read_bytes() == before
+
+
+def python_source(root):
+ source = root / "caller.py"
+ source.write_text(
+ "import requests\n\ndef fetch():\n return requests.get('/data')\n\n"
+ "def shadowed(requests):\n return requests.get('/private')\n",
+ encoding="utf-8",
+ )
+ return source
+
+
+@pytest.mark.parametrize("old_schema", [5, 12])
+@pytest.mark.parametrize("old_version", ["0.9.74", "0.9.76"])
+def test_req_qml003_ac04_qml018_ac05_old_namespaces_miss_and_refresh(
+ tmp_path, monkeypatch, integrated_versions, old_schema, old_version):
+ """Real legacy writes miss even at the same version, refresh, then warm-hit."""
+ source = python_source(tmp_path)
+ semantic = semantic_entry(tmp_path)
+ # Both historical producers used this real cache writer. Their raw calls
+ # cannot establish the integrated lexical/Qt fact contract from their epoch.
+ with monkeypatch.context() as prior:
+ prior.setattr(cache, "_EXTRACTOR_VERSION", old_version)
+ prior.setattr(cache, "_AST_CACHE_SCHEMA", old_schema)
+ cache.save_cached(source, {
+ "nodes": [{"id": "old", "label": "stale", "source_file": str(source)}],
+ "edges": [], "raw_calls": [{"caller_nid": "old", "callee": "get"}],
+ }, root=tmp_path)
+ old_directory = cache.cache_dir(tmp_path)
+ assert (old_directory / f"{cache.file_hash(source, tmp_path)}.json").exists()
+ assert cache.load_cached(source, root=tmp_path) is None
+ assert not old_directory.exists()
+ assert_semantic_preserved(tmp_path, semantic)
+ cold = extraction.extract([source], root=tmp_path, cache_root=tmp_path, parallel=False)
+ fresh = cache.load_cached(source, root=tmp_path)
+ assert fresh is not None
+ assert {item.get("_python_receiver_shadowed") for item in fresh["raw_calls"]} == {False, True}
+ assert cache.cache_dir(tmp_path).name == "v0.9.76-s13"
+ warm = extraction.extract([source], root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert normalized(build_from_json(cold, directed=True, root=tmp_path)) == normalized(
+ build_from_json(warm, directed=True, root=tmp_path))
+ assert not any(node.get("label") == "stale" for node in warm["nodes"])
+ assert_semantic_preserved(tmp_path, semantic)
+
+
+def test_req_qml003_ac04_qml018_ac05_mixed_cold_warm_serialized_facts(
+ tmp_path, monkeypatch, integrated_versions):
+ """Real Qt reparse and Python warm hits agree through directed JSON reload."""
+ project(tmp_path)
+ source = python_source(tmp_path)
+ semantic = semantic_entry(tmp_path)
+ paths = extraction.collect_files(tmp_path, root=tmp_path)
+ cold = extraction.extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ observed, real_extract = [], extraction._safe_extract_with_xaml_root
+
+ def observe(extractor, path, *args, **kwargs):
+ observed.append(Path(path).suffix)
+ return real_extract(extractor, path, *args, **kwargs)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ warm = extraction.extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert observed and ".py" not in observed
+ assert not warm["failed_sources"] and not warm["qml_failures"]
+ assert any(qt_metadata(node).get("kind") == "property" for node in warm["nodes"])
+ assert any(qml_metadata(node).get("kind") == "type_use" and
+ qml_metadata(node).get("status") == "resolved" for node in warm["nodes"])
+ callers = {node["label"]: node["id"] for node in warm["nodes"]
+ if node.get("source_file") == "caller.py"}
+ assert any(edge["source"] == callers["fetch()"] and edge["target"] == "requests"
+ and edge["relation"] == "calls" for edge in warm["edges"])
+ assert not any(edge["source"] == callers["shadowed()"] and edge["target"] == "requests"
+ and edge["relation"] == "calls" for edge in warm["edges"])
+ raw = cache.load_cached(source, root=tmp_path)
+ assert raw is not None
+ assert {item.get("_python_receiver_shadowed") for item in raw["raw_calls"]} == {False, True}
+ graphs = []
+ for name, result in (("cold", cold), ("warm", warm)):
+ graph = build_from_json(result, directed=True, root=tmp_path)
+ output = tmp_path / f"{name}.json"
+ assert to_json(graph, {}, str(output), force=True)
+ graphs.append(load_node_link_graph(json.loads(output.read_text(encoding="utf-8"))))
+ assert normalized(graphs[0]) == normalized(graphs[1])
+ assert_semantic_preserved(tmp_path, semantic)
+
+
+def test_req_qml011_ac03_ac04_qml018_ac06_upgrade_failure_retains_repairs_repeats(
+ tmp_path, monkeypatch, integrated_versions):
+ """A real schema-13 cache replacement failure cannot advance prior products."""
+ project(tmp_path)
+ source = python_source(tmp_path)
+ semantic = semantic_entry(tmp_path)
+ real_version = integrated_versions
+ with monkeypatch.context() as prior:
+ prior.setattr(cache, "_EXTRACTOR_VERSION", "0.9.74")
+ prior.setattr(cache, "_AST_CACHE_SCHEMA", 12)
+ prior.setattr(qt_state, "version", lambda name:
+ "0.9.74" if name == "graphifyy" else real_version(name))
+ assert _rebuild_code(tmp_path, no_cluster=True, acquire_lock=False)
+ output = tmp_path / "graphify-out"
+ accepted, initial = snapshot(output), normalized(published(tmp_path))
+ before_sources = {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()}
+ failed_targets, real_replace = [], cache._os_replace_with_fallback
+
+ def fail_current_ast(temporary, target):
+ target = Path(target)
+ if target.parent.name == "v0.9.76-s13":
+ # Serialization and staging must really finish before the injected
+ # replacement failure reaches the actual cache persistence owner.
+ assert Path(temporary).is_file()
+ staged = json.loads(Path(temporary).read_text(encoding="utf-8"))
+ assert staged.get("raw_calls")
+ failed_targets.append(target)
+ raise OSError("public combined-cache replacement failure")
+ return real_replace(temporary, target)
+
+ with monkeypatch.context() as failed:
+ failed.setattr(cache, "_os_replace_with_fallback", fail_current_ast)
+ assert not _rebuild_code(tmp_path, changed_paths=[], no_cluster=True, acquire_lock=False)
+ assert failed_targets
+ assert snapshot(output) == accepted
+ assert_semantic_preserved(tmp_path, semantic)
+ no_scratch(output)
+ assert _rebuild_code(tmp_path, changed_paths=[], no_cluster=True, acquire_lock=False)
+ assert normalized(published(tmp_path)) == initial
+ repaired = snapshot(output)
+ assert repaired != accepted
+ assert cache.load_cached(source, root=tmp_path) is not None
+ assert _rebuild_code(tmp_path, changed_paths=[], no_cluster=True, acquire_lock=False)
+ assert snapshot(output) == repaired
+ assert normalized(published(tmp_path)) == initial
+ assert_semantic_preserved(tmp_path, semantic)
+ assert before_sources == {path.name: path.read_bytes() for path in tmp_path.iterdir() if path.is_file()}
+ no_scratch(output)
diff --git a/tests/test_upstream_qt_discovery_integration.py b/tests/test_upstream_qt_discovery_integration.py
new file mode 100644
index 0000000000..0459343be7
--- /dev/null
+++ b/tests/test_upstream_qt_discovery_integration.py
@@ -0,0 +1,178 @@
+"""INC-QML-49: upstream corpus exclusions coexist with Qt metadata discovery."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+import graphify.detect as detection
+import graphify.extract as extraction
+from graphify.build import build_from_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.watch import _rebuild_code
+from tests.test_qt_final_incremental_parity import (
+ assert_native_fixture, normalized, project, run,
+)
+from tests.test_qt_product_publication import no_scratch, snapshot
+from tests.test_source_alias_provenance import directory_alias # noqa: F401
+from tests.test_watch_physical_coowners import cache_bytes
+
+
+INSTALLED = ".opencode/plugins/graphify.js"
+ORDINARY = "src/plugins/graphify.js"
+MANIFESTS = {"CMakeLists.txt", "qmltools/qmldir", "qmltools/tools.pro"}
+
+
+def corpus(root):
+ """A real native bridge, independent qmake module and copied hook share one scan."""
+ root.mkdir()
+ project(root)
+ files = {
+ INSTALLED: "// graphify generated hook\nfunction copiedHook() { return 7; }\n",
+ ORDINARY: "// graphify generated hook\nfunction copiedHook() { return 7; }\n",
+ "qmltools/qmldir": "module Public.Hidden\nHidden 1.0 Hidden.qml\n",
+ "qmltools/tools.pro": "QT += qml\nQML_IMPORT_NAME = Public.Hidden\n"
+ "QML_IMPORT_VERSION = 1.0\nQML_FILES += Hidden.qml\n",
+ "qmltools/Hidden.qml": "import QtQml\nQtObject { property int localValue: 7 }\n",
+ "NOT_METADATA": "Item { property int excluded: 1 }\n",
+ ".graphifyignore": "blocked/\n",
+ }
+ for name, text in files.items():
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ # Malformed excluded metadata must never reach an extractor or resolver.
+ (root / "blocked").mkdir()
+ for name in ("CMakeLists.txt", "qmldir", "Hidden.qml"):
+ (root / "blocked" / name).write_bytes(b"\xff\x00")
+
+
+def collected(root, follow):
+ return extraction.collect_files(root, root=root, follow_symlinks=follow)
+
+
+def direct(root, cache, follow):
+ """Exercise the actual collector, facade, Qt resolver and graph assembly."""
+ facts = extraction.extract(collected(root, follow), root=root, cache_root=cache,
+ parallel=False)
+ assert not facts["failed_sources"] and not facts["qml_failures"]
+ return build_from_json(facts, root=root)
+
+
+def assert_membership(graph):
+ sources = {node.get("source_file") for _, node in graph.nodes(data=True)}
+ assert MANIFESTS <= sources and ORDINARY in sources and INSTALLED not in sources
+ assert "NOT_METADATA" not in sources
+ assert not any(source and source.startswith(("blocked/", "outside/")) for source in sources)
+ assert any(qml_metadata(node).get("kind") == "qt_module"
+ for _, node in graph.nodes(data=True) if node.get("source_file") == "CMakeLists.txt")
+ assert any(qml_metadata(node).get("kind") == "module"
+ for _, node in graph.nodes(data=True) if node.get("source_file") == "qmltools/qmldir")
+ assert_native_fixture(graph)
+
+
+@pytest.mark.parametrize("follow", [False, True])
+def test_req_qml002_ac02_ac03_detect_and_collect_share_named_metadata_and_copy_exclusions(
+ tmp_path, follow):
+ """Both directory profiles keep exact-name manifests and reject only installed path shapes."""
+ root = tmp_path / "repo"
+ corpus(root)
+ detected = {Path(path).relative_to(root).as_posix()
+ for paths in detection.detect(root, follow_symlinks=follow)["files"].values()
+ for path in paths}
+ actual = {path.relative_to(root).as_posix() for path in collected(root, follow)}
+ for sources in (detected, actual):
+ assert MANIFESTS <= sources and ORDINARY in sources and INSTALLED not in sources
+ assert "NOT_METADATA" not in sources
+ assert not any(path.startswith("blocked/") for path in sources)
+
+
+def test_req_qml002_ac03_ac04_followed_metadata_keeps_lexical_scope_and_rejects_foreign_input(
+ tmp_path, directory_alias):
+ """Actual junctions/symlinks retain portable admitted names and exclude an external malformed module."""
+ root, foreign = tmp_path / "repo", tmp_path / "foreign"
+ corpus(root)
+ foreign.mkdir()
+ for name in ("CMakeLists.txt", "qmldir", "Foreign.qml"):
+ (foreign / name).write_bytes(b"\xff\x00")
+ directory_alias(root / "linked module Ω", root / "qmltools")
+ directory_alias(root / "outside", foreign)
+ paths = {path.relative_to(root).as_posix() for path in collected(root, True)}
+ assert {"linked module Ω/qmldir", "linked module Ω/tools.pro", "linked module Ω/Hidden.qml"} <= paths
+ assert not any(path.startswith("outside/") for path in paths)
+
+
+@pytest.mark.parametrize("follow", [False, True])
+def test_req_qml002_ac02_direct_cold_warm_facts_keep_qt_metadata_and_real_application_code(
+ tmp_path, follow):
+ """The facade preserves metadata/native joins while the copied installed hook contributes no facts."""
+ root = tmp_path / "repo"
+ corpus(root)
+ cold = direct(root, tmp_path / "cold", follow)
+ warm = direct(root, tmp_path / "cold", follow)
+ assert_membership(cold)
+ assert normalized(cold) == normalized(warm)
+
+
+@pytest.mark.parametrize("follow", [False, True])
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac03_ac04_updates_preserve_exclusions_metadata_and_clean_parity(
+ tmp_path, monkeypatch, operation, follow):
+ """Real publication refreshes admitted metadata/code, ignores copied hook edits and is repeatable."""
+ root = tmp_path / "repo"
+ corpus(root)
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ graph = run(root, monkeypatch, operation, follow_symlinks=follow)
+ assert_membership(graph)
+ before = snapshot(root / "graphify-out")
+ (root / INSTALLED).write_text("function replacementPoison() { return 9; }\n", encoding="utf-8")
+ assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=follow)) == normalized(graph)
+ assert snapshot(root / "graphify-out") == before
+ metadata, source = root / "qmltools/qmldir", root / ORDINARY
+ metadata.write_text("module Public.Hidden\nHidden 1.0 Hidden.qml\nprefer :/hidden/\n", encoding="utf-8")
+ source.write_text("function applicationEdit() { return 11; }\n", encoding="utf-8")
+ updated = run(root, monkeypatch, operation, [metadata, source], follow_symlinks=follow)
+ assert_membership(updated)
+ assert any(qml_metadata(node).get("kind") == "prefer"
+ and qml_metadata(node).get("value") == ":/hidden/"
+ for _, node in updated.nodes(data=True)
+ if node.get("source_file") == "qmltools/qmldir")
+ assert normalized(updated) != normalized(graph)
+ assert normalized(updated) == normalized(direct(root, tmp_path / "clean", follow))
+ accepted = snapshot(root / "graphify-out")
+ assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=follow)) == normalized(updated)
+ assert snapshot(root / "graphify-out") == accepted
+ no_scratch(root / "graphify-out")
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml018_ac06_malformed_admitted_metadata_retains_cohort_and_recovers(
+ tmp_path, monkeypatch, operation):
+ """A real invalid UTF-8 manifest fails before publication; repair and repeat retain correct facts."""
+ root = tmp_path / "repo"
+ corpus(root)
+ monkeypatch.chdir(root)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(root, monkeypatch, operation, follow_symlinks=True)
+ source, output = root / "qmltools/qmldir", root / "graphify-out"
+ original, before, cached = source.read_bytes(), snapshot(output), cache_bytes(output)
+ assert cached
+ source.write_bytes(b"\xff\x00")
+ if operation == "manual":
+ with pytest.raises(SystemExit) as rejected:
+ run(root, monkeypatch, operation, follow_symlinks=True)
+ assert rejected.value.code == 1
+ else:
+ assert not _rebuild_code(root, changed_paths=[source], no_cluster=True,
+ follow_symlinks=True)
+ assert snapshot(output) == before and cache_bytes(output) == cached
+ no_scratch(output)
+ source.write_bytes(original)
+ repaired = run(root, monkeypatch, operation, [source], follow_symlinks=True)
+ assert normalized(repaired) == normalized(initial)
+ assert_membership(repaired)
+ assert normalized(repaired) == normalized(direct(root, tmp_path / "clean", True))
+ accepted = snapshot(output)
+ assert normalized(run(root, monkeypatch, operation, [], follow_symlinks=True)) == normalized(repaired)
+ assert snapshot(output) == accepted
diff --git a/tests/test_upstream_qt_export_integration.py b/tests/test_upstream_qt_export_integration.py
new file mode 100644
index 0000000000..939c6d48c3
--- /dev/null
+++ b/tests/test_upstream_qt_export_integration.py
@@ -0,0 +1,100 @@
+"""INC-QML-49: preserved upstream links share Qt's durable export validation."""
+from __future__ import annotations
+
+import copy
+import json
+
+import networkx as nx
+import pytest
+
+import graphify.__main__ as mainmod
+from graphify.export import to_json
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.paths import load_node_link_graph
+from tests.test_qt_json_direction import actual_graph, damage
+
+
+def mixed_graph(root):
+ """Real QML facts and ordinary parallel transport exercise one output boundary."""
+ graph, source, target = actual_graph(root)
+ # Reverse undirected insertion order to make raw storage differ from the
+ # accepted QML read direction; generic links have no Qt marker transport.
+ reversed_graph = nx.Graph()
+ reversed_graph.add_node(target, **graph.nodes[target])
+ reversed_graph.add_nodes_from((key, data) for key, data in graph.nodes(data=True) if key != target)
+ reversed_graph.add_edges_from((v, u, copy.deepcopy(data)) for u, v, data in graph.edges(data=True))
+ reversed_graph.add_node("ordinary_caller", label="caller", source_file="ordinary.py", file_type="code")
+ reversed_graph.add_node("ordinary_target", label="target", source_file="ordinary.py", file_type="code")
+ reversed_graph.add_edge("ordinary_caller", "ordinary_target", relation="calls", confidence="EXTRACTED")
+ links = copy.deepcopy(nx.node_link_data(reversed_graph, edges="links")["links"])
+ links.append({"source": "ordinary_caller", "target": "ordinary_target",
+ "relation": "imports", "confidence": "EXTRACTED"})
+ return reversed_graph, links, source, target
+
+
+def assert_preserved(path, source, target):
+ """The durable file keeps both generic mechanisms and the actual QML proof."""
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ ordinary = [link for link in payload["links"] if link["source"] == "ordinary_caller"]
+ assert {link["relation"] for link in ordinary} == {"calls", "imports"} and len(ordinary) == 2
+ read = next(link for link in payload["links"] if link.get("context") == "qml_binding_read")
+ assert (read["source"], read["target"]) == (source, target)
+ assert read["source_location"] and qml_metadata(read)
+ assert "_src" not in read and "_tgt" not in read
+ loaded = load_node_link_graph(path)
+ assert loaded.has_edge(source, target)
+ assert loaded.nodes[source]["source_file"] == "Main.qml"
+ return payload
+
+
+def test_req_qml_010_ac02_original_parallel_links_retain_qml_direction(tmp_path):
+ """Upstream's original-link path preserves distinct relations and Qt source provenance."""
+ graph, links, source, target = mixed_graph(tmp_path)
+ before = copy.deepcopy(nx.node_link_data(graph, edges="links")), copy.deepcopy(links)
+ path = tmp_path / "graph.json"
+ assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed")
+ assert_preserved(path, source, target)
+ assert (nx.node_link_data(graph, edges="links"), links) == before
+ accepted = path.read_bytes()
+ assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed")
+ assert path.read_bytes() == accepted
+
+
+@pytest.mark.parametrize("force", [False, True])
+@pytest.mark.parametrize("corruption", ["partial_source", "foreign_target", "list", "collapsed"])
+def test_req_qml_018_ac06_preserved_invalid_link_refuses_replacement(tmp_path, capsys, force, corruption):
+ """A corrupt original link cannot hide behind the simple graph's accepted edge."""
+ graph, links, source, target = mixed_graph(tmp_path)
+ path = tmp_path / "previous.json"
+ assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed")
+ accepted, stamp = path.read_bytes(), path.stat().st_mtime_ns
+ malformed = copy.deepcopy(links)
+ read = next(link for link in malformed if link.get("context") == "qml_binding_read")
+ damage(read, corruption, source, target)
+ before = copy.deepcopy(malformed), copy.deepcopy(nx.node_link_data(graph, edges="links"))
+ assert to_json(graph, {}, str(path), original_links=malformed, force=force, built_at_commit="fixed") is False
+ assert path.read_bytes() == accepted and path.stat().st_mtime_ns == stamp
+ diagnostic = capsys.readouterr().err
+ assert "QT_EXPORT_DIRECTION" in diagnostic and "private-needle" not in diagnostic
+ assert (malformed, nx.node_link_data(graph, edges="links")) == before
+ assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed")
+ assert path.read_bytes() == accepted
+ assert_preserved(path, source, target)
+
+
+def test_req_qml_018_ac04_real_recluster_keeps_parallel_and_qml_links(tmp_path, monkeypatch, capsys):
+ """The production offline CLI reloads and writes every original relationship twice."""
+ graph, links, source, target = mixed_graph(tmp_path)
+ out = tmp_path / "graphify-out"
+ out.mkdir()
+ path = out / "graph.json"
+ assert to_json(graph, {}, str(path), original_links=links, built_at_commit="fixed")
+ previous = assert_preserved(path, source, target)
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "cluster-only", str(tmp_path), "--no-viz", "--no-label"])
+ for _ in range(2):
+ mainmod.main()
+ current = assert_preserved(path, source, target)
+ assert current["links"] == previous["links"]
+ assert current["built_at_commit"] == "fixed"
+ assert "Done -" in capsys.readouterr().out
diff --git a/tests/test_upstream_qt_path_integration.py b/tests/test_upstream_qt_path_integration.py
new file mode 100644
index 0000000000..aaabd56250
--- /dev/null
+++ b/tests/test_upstream_qt_path_integration.py
@@ -0,0 +1,77 @@
+"""INC-QML-49: upstream qualified endpoint identity retains real Qt/QML paths."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+import graphify.__main__ as mainmod
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from tests.test_qt_qml_integration import corpus
+
+
+def native_path(root, directed=False):
+ """Actual registered C++ source supplies a native target and typed QML call."""
+ result = corpus(root)
+ call = next(edge for edge in result["edges"] if edge.get("context") == "qml_js_call")
+ source, target = call["source"], call["target"]
+ assert qt_metadata(call)["native_endpoint"]["canonical_target_id"] == target
+ graph = build_from_json(result, root=root, directed=directed)
+ source_query = f'{graph.nodes[source]["source_file"]}::{graph.nodes[source]["label"]}'
+ target_query = f'{graph.nodes[target]["source_file"]}::{graph.nodes[target]["label"]}'
+ # A same-named declaration in another file may never steal the explicit
+ # native query or add a route unrelated to the accepted registration.
+ graph.add_node("decoy", label=graph.nodes[target]["label"], source_file="other.hpp", file_type="code")
+ path = root / "graph.json"
+ assert to_json(graph, {}, str(path), built_at_commit="fixed")
+ return path, source, target, source_query, target_query
+
+
+def run_path(monkeypatch, path, source, target, capsys, *flags):
+ """Exercise the public CLI; only its unrelated version notification is isolated."""
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "path", source, target, "--graph", str(path), *flags])
+ try:
+ mainmod.main()
+ except SystemExit as ended:
+ code = ended.code
+ else:
+ code = 0
+ captured = capsys.readouterr()
+ return code, captured.out, captured.err
+
+
+@pytest.mark.parametrize("directed", [False, True])
+def test_req_qml_010_ac03_qualified_native_path_preserves_direction(tmp_path, monkeypatch, capsys, directed):
+ """Qualified source names select the real QML-to-C++ edge through either graph mode."""
+ path, source, target, source_query, target_query = native_path(tmp_path, directed)
+ accepted = path.read_bytes()
+ code, output, error = run_path(monkeypatch, path, source_query, target_query, capsys)
+ assert code == 0 and not error
+ assert "Shortest path (1 hops):" in output and "--calls [INFERRED]-->" in output
+ assert "qml_js_call" in output and "backend.hpp" in output
+ code, output, error = run_path(monkeypatch, path, target_query, source_query, capsys)
+ assert code == 0 and not error and "No directed path found" in output
+ code, output, error = run_path(monkeypatch, path, target_query, source_query, capsys, "--undirected")
+ assert code == 0 and not error and "<--calls [INFERRED]--" in output
+ code, output, error = run_path(monkeypatch, path, source, target, capsys)
+ assert code == 0 and not error and "Shortest path (1 hops):" in output
+ assert path.read_bytes() == accepted
+
+
+def test_req_qml_018_ac04_duplicate_qualified_target_refuses_guess(tmp_path, monkeypatch, capsys):
+ """Two declarations in the same qualified file reject selection; exact ID still works."""
+ path, source, target, source_query, target_query = native_path(tmp_path)
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ original = next(node for node in payload["nodes"] if node["id"] == target)
+ payload["nodes"].append({**original, "id": "same_file_duplicate"})
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ accepted = path.read_bytes()
+ code, output, error = run_path(monkeypatch, path, source_query, target_query, capsys)
+ assert code == 1 and not output and "ambiguous" in error.lower()
+ assert target in error and "same_file_duplicate" in error
+ code, output, error = run_path(monkeypatch, path, source, target, capsys)
+ assert code == 0 and not error and "Shortest path (1 hops):" in output
+ assert path.read_bytes() == accepted
diff --git a/tests/test_upstream_qt_shared_integration.py b/tests/test_upstream_qt_shared_integration.py
new file mode 100644
index 0000000000..a60cd06262
--- /dev/null
+++ b/tests/test_upstream_qt_shared_integration.py
@@ -0,0 +1,155 @@
+"""INC-QML-49: upstream C++ union ownership coexists with Qt native facts."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from graphify.build import build_from_json
+from graphify.export import to_json
+from graphify.extract import collect_files, extract, extract_cpp
+from graphify.extractors.qml_facts import qml_metadata
+from graphify.extractors.qt_cpp_facts import qt_metadata
+from graphify.paths import load_node_link_graph
+from tests.test_qt_final_incremental_parity import clean, normalized, run
+
+
+HEADER = """#include
+union Value { int integer; float real; int fetch() { return integer; } };
+struct Point { int x; };
+typedef union { int anonymous; } Hidden;
+class Backend : public QObject { Q_OBJECT QML_NAMED_ELEMENT(Service)
+ Q_PROPERTY(int value READ value)
+public: int value() const { return 1; }
+ Q_INVOKABLE int fetch() { return 2; }
+};
+"""
+BUILDS = [
+ ("CMakeLists.txt", "qt_add_qml_module(app URI Public.Tools VERSION 1.0 "
+ "QML_FILES Main.qml SOURCES backend.h)\n"),
+ ("app.pro", "CONFIG += qmltypes\nQML_IMPORT_NAME = Public.Tools\n"
+ "QML_IMPORT_MAJOR_VERSION = 1\nHEADERS += backend.h\nDISTFILES += Main.qml\n"),
+]
+
+
+def project(root, build_name, build):
+ """A same-named union method is a real non-provider collision control."""
+ sources = {
+ build_name: build,
+ "backend.h": HEADER,
+ "Main.qml": "import Public.Tools 1.0 as Tools\n"
+ "Tools.Service { property int displayed: value; "
+ "property int called: fetch() }\n",
+ }
+ for name, text in sources.items():
+ (root / name).write_text(text, encoding="utf-8", newline="")
+ return root / "backend.h"
+
+
+def declaration(graph, label):
+ matches = [(identity, data) for identity, data in graph.nodes(data=True)
+ if data.get("source_file") == "backend.h" and data.get("label") == label]
+ assert len(matches) == 1, (label, matches)
+ return matches[0]
+
+
+def method(graph, owner):
+ """Generic display labels omit the class; stored ownership selects identity."""
+ matches = [target for _, target, data in graph.edges(owner, data=True)
+ if graph.nodes[target].get("label") == ".fetch()" and
+ data.get("relation") == "method"]
+ assert len(matches) == 1
+ return matches[0]
+
+
+def assert_joint_facts(graph, member="integer"):
+ """Check source-owned generic relationships and exact Qt endpoint identity."""
+ union, union_data = declaration(graph, "Value")
+ field, field_data = declaration(graph, member)
+ backend, _ = declaration(graph, "Backend")
+ union_fetch, backend_fetch = method(graph, union), method(graph, backend)
+ assert union_data["source_location"] == field_data["source_location"] == "L2"
+ assert declaration(graph, "Point")[1]["source_location"] == "L3"
+ assert any(target == field and data.get("relation") == "defines"
+ for _, target, data in graph.edges(union, data=True))
+ assert graph.nodes[union_fetch]["source_location"] == "L2"
+ assert graph.nodes[backend_fetch]["source_location"] == "L8"
+ assert not any(data.get("label") in {"Hidden", "anonymous"}
+ for _, data in graph.nodes(data=True))
+ calls = [(source, target, data) for source, target, data in graph.edges(data=True)
+ if data.get("context") == "qml_js_call"]
+ assert len(calls) == 1
+ source, target, call = calls[0]
+ proof = qt_metadata(call)["native_endpoint"]
+ assert (call.get("_src", source), call.get("_tgt", target))[1] == backend_fetch
+ assert proof["canonical_target_id"] == backend_fetch != union_fetch
+ assert graph.nodes[proof["class_id"]]["label"] == "Backend"
+ assert any(qml_metadata(graph.nodes[item]).get("kind") == "qt_module"
+ for item in proof["evidence"])
+ assert any(data.get("context") == "qml_binding_read" and
+ qt_metadata(data).get("native_endpoint")
+ for _, _, data in graph.edges(data=True))
+ assert all(graph.nodes[endpoint].get("source_file")
+ for endpoint in (union, field, union_fetch, backend_fetch))
+ return union, field, union_fetch, backend_fetch
+
+
+@pytest.mark.parametrize("build_name,build", BUILDS)
+def test_req_qml003_ac01_ac02_ac04_union_and_qt_native_cold_warm_reload(
+ tmp_path, monkeypatch, build_name, build):
+ """Real direct/facade parsing and warm replay retain both owners and spans."""
+ source = project(tmp_path, build_name, build)
+ raw = extract_cpp(source)
+ assert not raw.get("parse_errors")
+ raw_labels = {node["label"] for node in raw["nodes"]}
+ assert {"Value", "integer", "real", ".fetch()"} <= raw_labels
+ assert sum(node["label"] == ".fetch()" for node in raw["nodes"]) == 2
+ paths = collect_files(tmp_path, root=tmp_path)
+ cold = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert not cold["failed_sources"] and not cold["qml_failures"]
+ # The facade's generic C++ producer must really warm-hit; Qt's accepted
+ # overlay may still reparse the same source for its run-owned metadata.
+ import graphify.extract as extraction
+ observed, real_extract = [], extraction._safe_extract_with_xaml_root
+
+ def observe(path, *args, **kwargs):
+ observed.append(path)
+ return real_extract(path, *args, **kwargs)
+
+ monkeypatch.setattr(extraction, "_safe_extract_with_xaml_root", observe)
+ warm = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
+ assert source not in observed
+ assert not warm["failed_sources"] and not warm["qml_failures"]
+ graphs = []
+ for name, facts in (("cold", cold), ("warm", warm)):
+ graph = build_from_json(facts, root=tmp_path, directed=True)
+ assert_joint_facts(graph)
+ output = tmp_path / f"{name}.json"
+ assert to_json(graph, {}, str(output), force=True)
+ restored = load_node_link_graph(json.loads(output.read_text(encoding="utf-8")))
+ assert_joint_facts(restored)
+ graphs.append(restored)
+ assert normalized(graphs[0]) == normalized(graphs[1])
+
+
+@pytest.mark.parametrize("operation", ["manual", "watch"])
+def test_req_qml011_ac01_ac04_union_edit_removes_stale_member_keeps_native_endpoint(
+ tmp_path, monkeypatch, operation):
+ """A real generic union edit replaces only its member through both updates."""
+ source = project(tmp_path, *BUILDS[0])
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("GRAPHIFY_NO_TIPS", "1")
+ initial = run(tmp_path, monkeypatch, operation)
+ union, old_field, union_fetch, backend_fetch = assert_joint_facts(initial)
+ source.write_text(HEADER.replace("integer", "whole"), encoding="utf-8", newline="")
+ edited = run(tmp_path, monkeypatch, operation, [source])
+ current_union, new_field, current_union_fetch, current_backend_fetch = assert_joint_facts(edited, "whole")
+ assert (current_union, current_union_fetch, current_backend_fetch) == (union, union_fetch, backend_fetch)
+ assert new_field != old_field and old_field not in edited
+ assert not any(old_field in (start, end) for start, end in edited.edges())
+ fresh = clean(tmp_path, tmp_path / ".comparison-cache")
+ assert_joint_facts(fresh, "whole")
+ assert normalized(edited) == normalized(fresh)
+ accepted = (tmp_path / "graphify-out/graph.json").read_bytes()
+ assert normalized(run(tmp_path, monkeypatch, operation, [])) == normalized(edited)
+ assert (tmp_path / "graphify-out/graph.json").read_bytes() == accepted
diff --git a/tests/test_watch.py b/tests/test_watch.py
index d13f4b362a..4505e31ac0 100644
--- a/tests/test_watch.py
+++ b/tests/test_watch.py
@@ -807,6 +807,9 @@ def test_graphify_root_preserves_absolute_when_user_supplied(tmp_path):
)
+# Windows keeps the process CWD open and refuses its removal; the actual deleted
+# directory fixtures belong to POSIX. Portable OS-error seams live separately.
+@pytest.mark.skipif(os.name == "nt", reason="Windows cannot remove the active process CWD")
def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monkeypatch, capsys):
"""Detached hooks can inherit a CWD that no longer exists.
@@ -821,8 +824,8 @@ def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monk
monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False)
os.chdir(gone)
- gone.rmdir()
try:
+ gone.rmdir()
assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False
finally:
os.chdir(old_cwd)
@@ -831,6 +834,7 @@ def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monk
assert "current working directory no longer exists" in out
+@pytest.mark.skipif(os.name == "nt", reason="Windows cannot remove the active process CWD")
def test_rebuild_code_deleted_cwd_uses_graphify_repo_root(tmp_path, monkeypatch):
"""GRAPHIFY_REPO_ROOT lets detached hook rebuilds recover from a deleted CWD."""
from graphify.watch import _rebuild_code
@@ -844,8 +848,8 @@ def test_rebuild_code_deleted_cwd_uses_graphify_repo_root(tmp_path, monkeypatch)
monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(corpus))
os.chdir(gone)
- gone.rmdir()
try:
+ gone.rmdir()
assert _rebuild_code(
Path("."),
changed_paths=[Path("lib.py")],
diff --git a/tests/test_watch_coowner_admission.py b/tests/test_watch_coowner_admission.py
new file mode 100644
index 0000000000..f40e86f4ba
--- /dev/null
+++ b/tests/test_watch_coowner_admission.py
@@ -0,0 +1,70 @@
+"""INC-QML-44: physical equality cannot override watch admission or semantic ownership."""
+from __future__ import annotations
+
+import json
+import os
+
+import graphify.extract as extraction
+from graphify.watch import _rebuild_code
+from tests.test_qt_final_incremental_parity import normalized
+from tests.test_watch_physical_coowners import published
+
+
+def observed_inputs(monkeypatch, root):
+ """Observe real facade work while leaving parsing and publication intact."""
+ observed, real_extract = [], extraction.extract
+
+ def inputs(paths, *args, **kwargs):
+ paths = list(paths)
+ observed.append({path.relative_to(root).as_posix() for path in paths})
+ return real_extract(paths, *args, **kwargs)
+
+ monkeypatch.setattr(extraction, "extract", inputs)
+ return observed
+
+
+def test_req_qml011_ac01_semantic_backed_hardlink_keeps_its_own_tier(monkeypatch, tmp_path):
+ """One physical edit refreshes the AST owner and preserves a semantic sibling verbatim."""
+ root = tmp_path / "repo"
+ root.mkdir()
+ target, alias = root / "plain.md", root / "semantic.md"
+ target.write_text("# Previous\n\nText.\n", encoding="utf-8")
+ os.link(target, alias)
+ assert target.stat().st_ino == alias.stat().st_ino
+ assert _rebuild_code(root, no_cluster=True, acquire_lock=False)
+ path = root / "graphify-out/graph.json"
+ data = json.loads(path.read_text(encoding="utf-8"))
+ # Seed only an external semantic result, as the existing semantic watch
+ # fixtures do. Selection, AST reconciliation and persistence remain real.
+ data["nodes"].append({"id": "semantic_concept", "label": "Retained concept",
+ "file_type": "concept", "source_file": "semantic.md"})
+ path.write_text(json.dumps(data), encoding="utf-8")
+ before, _ = normalized(published(root))
+ sibling = {identity: value for identity, value in before.items()
+ if published(root).nodes[identity].get("source_file") == "semantic.md"}
+ observed = observed_inputs(monkeypatch, root)
+ target.write_text("# Updated\n\nChanged.\n", encoding="utf-8")
+ assert _rebuild_code(root, changed_paths=[target], no_cluster=True, acquire_lock=False)
+ actual = published(root)
+ after, _ = normalized(actual)
+ assert observed == [{"plain.md"}]
+ assert sibling == {identity: value for identity, value in after.items()
+ if actual.nodes[identity].get("source_file") == "semantic.md"}
+ assert any(node.get("label") == "Updated" for _, node in actual.nodes(data=True)
+ if node.get("source_file") == "plain.md")
+
+
+def test_req_qml011_ac01_excluded_hardlink_cannot_reenter_the_corpus(monkeypatch, tmp_path):
+ """A physically equal excluded source is absent from work and durable output."""
+ root = tmp_path / "repo"
+ root.mkdir()
+ target, alias = root / "accepted.py", root / "excluded.py"
+ target.write_text("def previous():\n return 1\n", encoding="utf-8")
+ os.link(target, alias)
+ (root / ".graphifyignore").write_text("excluded.py\n", encoding="utf-8")
+ assert _rebuild_code(root, no_cluster=True, acquire_lock=False)
+ observed = observed_inputs(monkeypatch, root)
+ target.write_text("def updated():\n return 2\n", encoding="utf-8")
+ assert _rebuild_code(root, changed_paths=[target], no_cluster=True, acquire_lock=False)
+ assert observed == [{"accepted.py"}]
+ assert all(node.get("source_file") != "excluded.py" for _, node in published(root).nodes(data=True))
diff --git a/tests/test_watch_physical_coowners.py b/tests/test_watch_physical_coowners.py
new file mode 100644
index 0000000000..cf0bb1069f
--- /dev/null
+++ b/tests/test_watch_physical_coowners.py
@@ -0,0 +1,280 @@
+"""INC-QML-44: physical co-owner invalidation through real watch publication."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+import pytest
+
+from graphify.build import build_from_json
+import graphify.extract as extraction
+import graphify.qt_analysis_state as state
+import graphify.qt_incremental as policy
+from graphify.paths import load_node_link_graph
+from graphify.watch import _rebuild_code
+import graphify.watch_coowners as coowners
+from tests.test_qt_final_incremental_parity import normalized, project, unrelated
+from tests.test_qt_product_publication import no_scratch, snapshot
+
+
+SOURCE = b"def helper():\n return 7\n\ndef caller():\n return helper()\n"
+
+
+def owners(tmp_path, request, kind, *, mixed=False):
+ """Only an admitted directory alias or real hardlink shares source content."""
+ root = tmp_path / "repo"
+ (root / "real").mkdir(parents=True)
+ if mixed:
+ project(root)
+ else:
+ (root / "keep.py").write_bytes(SOURCE.replace(b"helper", b"independent"))
+ target = root / "real/member.py"
+ target.write_bytes(SOURCE)
+ if kind == "directory":
+ request.getfixturevalue("directory_alias")(root / "linked", target.parent)
+ else:
+ (root / "linked").mkdir()
+ os.link(target, root / "linked/member.py")
+ alias = root / "linked/member.py"
+ assert target.stat().st_ino and target.stat().st_dev
+ assert (target.stat().st_dev, target.stat().st_ino) == (alias.stat().st_dev, alias.stat().st_ino)
+ return root, target, alias
+
+
+# Reuse the real link fixture, not its selection or graph behavior.
+from tests.test_source_alias_provenance import directory_alias # noqa: E402,F401
+
+
+def published(root):
+ return load_node_link_graph(json.loads((root / "graphify-out/graph.json").read_text(encoding="utf-8")))
+
+
+def complete(root, cache):
+ paths = extraction.collect_files(root, follow_symlinks=True, root=root)
+ result = extraction.extract(paths, root=root, cache_root=cache, parallel=False)
+ assert not result["failed_sources"] and not result["qml_failures"]
+ return build_from_json(result, root=root)
+
+
+def cache_bytes(output):
+ """Compare actual cache payloads, not only the published graph cohort."""
+ return {path.relative_to(output).as_posix(): path.read_bytes()
+ for path in (output / "cache").rglob("*") if path.is_file()}
+
+
+@pytest.mark.parametrize("kind", ["directory", "hardlink"])
+@pytest.mark.parametrize("mixed", [False, True])
+def test_req_qml011_ac01_ac03_qml020_ac03_one_owner_edit_refreshes_shared_content(
+ tmp_path, request, monkeypatch, kind, mixed):
+ """One notification retires both old APIs; unrelated/native facts remain stable."""
+ root, target, alias = owners(tmp_path, request, kind, mixed=mixed)
+ assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ before = published(root)
+ independent = unrelated(before)
+ observed, real_extract = [], extraction.extract
+
+ def inputs(paths, *args, **kwargs):
+ paths = list(paths)
+ observed.append({path.relative_to(root).as_posix() for path in paths})
+ return real_extract(paths, *args, **kwargs)
+
+ monkeypatch.setattr(extraction, "extract", inputs)
+ target.write_bytes(SOURCE.replace(b"helper", b"updated"))
+ assert _rebuild_code(root, changed_paths=[alias], follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ actual = published(root)
+ assert observed == [{"real/member.py", "linked/member.py"}]
+ assert "real_member_helper" not in actual and "linked_member_helper" not in actual
+ assert {"real_member_updated", "linked_member_updated"} <= set(actual)
+ assert unrelated(actual) == independent
+ cold = complete(root, tmp_path / "full-cache")
+ assert normalized(actual) == normalized(cold)
+ assert normalized(complete(root, tmp_path / "full-cache")) == normalized(cold)
+ accepted = snapshot(root / "graphify-out")
+ observed.clear()
+ assert _rebuild_code(root, changed_paths=[], follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ assert not observed and snapshot(root / "graphify-out") == accepted
+
+
+@pytest.mark.parametrize("kind", ["directory", "hardlink"])
+def test_req_qml011_ac03_normal_rename_and_deletion_retire_only_absent_owners(
+ tmp_path, request, kind):
+ """A normal disappearance before detection is deletion, not an identity failure."""
+ root, target, _ = owners(tmp_path, request, kind)
+ assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ renamed = target.with_name("renamed.py")
+ target.rename(renamed)
+ assert _rebuild_code(root, changed_paths=[target, renamed], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ actual = published(root)
+ assert "real_member" not in actual and "real_renamed" in actual
+ assert normalized(actual) == normalized(complete(root, tmp_path / "rename-cache"))
+ renamed.unlink()
+ assert _rebuild_code(root, changed_paths=[renamed], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ actual = published(root)
+ assert "real_renamed" not in actual
+ assert ("linked_member" in actual) == (kind == "hardlink")
+ assert normalized(actual) == normalized(complete(root, tmp_path / "delete-cache"))
+
+
+@pytest.mark.parametrize("operation,fault", [("resolve", OSError), ("resolve", RuntimeError),
+ ("stat", OSError), ("stat", RuntimeError),
+ ("disappearance", FileNotFoundError)])
+def test_req_qml012_ac01_ac02_identity_failure_retains_recovers_and_repeats(
+ tmp_path, request, monkeypatch, capsys, operation, fault):
+ """A real admitted lookup failure preserves every published product and hides its body."""
+ root, target, alias = owners(tmp_path, request, "directory", mixed=True)
+ assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ output = root / "graphify-out"
+ before = snapshot(output)
+ cached = cache_bytes(output)
+ assert cached
+ target.write_bytes(SOURCE.replace(b"helper", b"updated"))
+ real_identity = coowners.physical_identity
+ raw_body = "private backend response token=must-never-appear"
+ observed = []
+
+ def failed_identity(path, anchor):
+ if path != alias:
+ return real_identity(path, anchor)
+ observed.append(path)
+ if operation == "disappearance":
+ # Actual race after admitted detection, before verified co-ownership.
+ target.unlink()
+ return real_identity(path, anchor)
+ real_operation = getattr(Path, operation)
+
+ def unavailable(source, *args, **kwargs):
+ if source == alias:
+ raise fault(raw_body)
+ return real_operation(source, *args, **kwargs)
+
+ with monkeypatch.context() as lookup:
+ lookup.setattr(Path, operation, unavailable)
+ return real_identity(path, anchor)
+
+ with monkeypatch.context() as failed:
+ failed.setattr(coowners, "physical_identity", failed_identity)
+ assert not _rebuild_code(root, changed_paths=[alias], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ assert observed and snapshot(output) == before
+ assert cache_bytes(output) == cached
+ captured = capsys.readouterr()
+ text = captured.out + captured.err
+ assert "WATCH_SOURCE_IDENTITY_FAILED" in text and 'source="linked/member.py"' in text
+ assert raw_body not in text and str(root) not in text.split("Rebuild failed:")[-1]
+ no_scratch(output)
+ target.write_bytes(SOURCE.replace(b"helper", b"updated"))
+ assert _rebuild_code(root, changed_paths=[alias], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ assert normalized(published(root)) == normalized(complete(root, tmp_path / "repair-cache"))
+ accepted = snapshot(output)
+ assert _rebuild_code(root, changed_paths=[alias], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ assert snapshot(output) == accepted
+ no_scratch(output)
+
+
+@pytest.mark.parametrize("label", ["a\n.py", "a\x7f.py", "a\u2028.py", "x" * 158 + ".py"])
+def test_req_qml012_ac04_unsafe_or_long_missing_identity_context_is_empty(tmp_path, label):
+ """Rejected lookup context is bounded independently of backend path/error text."""
+ with pytest.raises(ValueError) as rejected:
+ coowners.physical_identity(tmp_path / label, tmp_path)
+ assert str(rejected.value) == ('WATCH_SOURCE_IDENTITY_FAILED: source=""; admitted source '
+ 'identity unavailable; prior products retained; repair and retry')
+
+
+def test_req_qml011_ac01_selection_cannot_discover_foreign_or_unadmitted_coowners(tmp_path):
+ """A supplied change cannot bypass admission; a true foreign corpus path is rejected."""
+ root, foreign = tmp_path / "repo", tmp_path / "foreign.py"
+ root.mkdir()
+ local = root / "local.py"
+ local.write_bytes(SOURCE)
+ foreign.write_bytes(SOURCE)
+ with pytest.raises(ValueError, match="WATCH_SOURCE_IDENTITY_FAILED"):
+ coowners.expand_changed_coowners([foreign], [local], root=root)
+ with pytest.raises(ValueError, match="WATCH_SOURCE_IDENTITY_FAILED"):
+ coowners.expand_changed_coowners([local], [local, foreign], root=root)
+ with pytest.raises(ValueError, match="WATCH_SOURCE_IDENTITY_FAILED"):
+ coowners.expand_changed_coowners([local], [local, root], root=root)
+ assert coowners.expand_changed_coowners([], [foreign], root=root) == []
+ assert local.read_bytes() == foreign.read_bytes() == SOURCE
+
+
+def test_req_qml011_ac01_missing_inode_identity_uses_contained_path_without_false_joins(
+ tmp_path, request, monkeypatch):
+ """A filesystem without usable inode IDs still proves only resolved-path aliases."""
+ root, target, alias = owners(tmp_path, request, "directory")
+ separate = root / "separate.py"
+ separate.write_bytes(SOURCE)
+ original_stat = Path.stat
+
+ def unidentified(path, *args, **kwargs):
+ value = list(original_stat(path, *args, **kwargs))
+ value[1] = value[2] = 0
+ return os.stat_result(value)
+
+ monkeypatch.setattr(Path, "stat", unidentified)
+ assert coowners.expand_changed_coowners([alias], [target, alias, separate], root=root) == [alias, target]
+
+
+def test_req_qml012_ac04_context_quotes_delimiters_and_preserves_160_character_boundary(tmp_path):
+ """Unavailable path labels remain one bounded quoted field, never backend instructions."""
+ for label in ('a"; delimiter.py', "x" * 157 + ".py"):
+ with pytest.raises(ValueError) as rejected:
+ coowners.physical_identity(tmp_path / label, tmp_path)
+ text = str(rejected.value)
+ assert f"source={json.dumps(label, ensure_ascii=True)};" in text
+ assert len(label) <= coowners.SOURCE_CONTEXT_LIMIT and "\n" not in text
+ assert str(tmp_path) not in text
+
+
+def test_req_qml011_ac03_ac04_qml020_ac03_policy20_refresh_retains_repairs_and_repeats(
+ tmp_path, request, monkeypatch, capsys):
+ """An unchanged prior Qt cohort refreshes both owners; stamp failure cannot publish it."""
+ root, _, _ = owners(tmp_path, request, "hardlink", mixed=True)
+ with monkeypatch.context() as prior:
+ prior.setattr(policy, "QT_POLICY_VERSION", 20)
+ assert _rebuild_code(root, follow_symlinks=True, no_cluster=True, acquire_lock=False)
+ output = root / "graphify-out"
+ before, initial = snapshot(output), normalized(published(root))
+ fingerprint = state.read_qt_fingerprint(output)
+ observed, real_extract = [], extraction.extract
+
+ def inputs(paths, *args, **kwargs):
+ paths = list(paths)
+ observed.append({path.relative_to(root).as_posix() for path in paths})
+ return real_extract(paths, *args, **kwargs)
+
+ monkeypatch.setattr(extraction, "extract", inputs)
+ staged, real_commit = [], state.commit_qt_analysis
+
+ def stage_then_fail(*args, **kwargs):
+ real_commit(*args, **kwargs)
+ staged.append(True)
+ raise OSError("public co-owner policy upgrade publication failure")
+
+ with monkeypatch.context() as failed:
+ failed.setattr(state, "commit_qt_analysis", stage_then_fail)
+ assert not _rebuild_code(root, changed_paths=[], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ assert staged == [True] and snapshot(output) == before
+ assert state.read_qt_fingerprint(output) == fingerprint
+ assert "GRAPH_PUBLICATION_FAILED" in capsys.readouterr().out
+ required = {"real/member.py", "linked/member.py", "Main.qml", "backend.h", "loader.cpp"}
+ assert observed and required <= set.union(*observed)
+ no_scratch(output)
+ observed.clear()
+ assert _rebuild_code(root, changed_paths=[], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ assert normalized(published(root)) == initial
+ assert state.read_qt_fingerprint(output) != fingerprint
+ assert observed and required <= set.union(*observed)
+ assert normalized(published(root)) == normalized(complete(root, tmp_path / "upgrade-cache"))
+ accepted = snapshot(output)
+ observed.clear()
+ assert _rebuild_code(root, changed_paths=[], follow_symlinks=True,
+ no_cluster=True, acquire_lock=False)
+ assert not any(observed) and snapshot(output) == accepted
+ no_scratch(output)
diff --git a/tools/skillgen/expected/graphify__skill-agents.md b/tools/skillgen/expected/graphify__skill-agents.md
index 3f7fade819..87d10b0802 100644
--- a/tools/skillgen/expected/graphify__skill-agents.md
+++ b/tools/skillgen/expected/graphify__skill-agents.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-aider.md b/tools/skillgen/expected/graphify__skill-aider.md
index 6e459945af..f873f01a60 100644
--- a/tools/skillgen/expected/graphify__skill-aider.md
+++ b/tools/skillgen/expected/graphify__skill-aider.md
@@ -195,10 +195,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -208,7 +216,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files)
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
@@ -766,6 +779,10 @@ The graph is the map. Your job after the pipeline is to be the guide.
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skill-amp.md b/tools/skillgen/expected/graphify__skill-amp.md
index 3f7fade819..87d10b0802 100644
--- a/tools/skillgen/expected/graphify__skill-amp.md
+++ b/tools/skillgen/expected/graphify__skill-amp.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-claw.md b/tools/skillgen/expected/graphify__skill-claw.md
index 2eca2a295f..1f4d24162f 100644
--- a/tools/skillgen/expected/graphify__skill-claw.md
+++ b/tools/skillgen/expected/graphify__skill-claw.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-codex.md b/tools/skillgen/expected/graphify__skill-codex.md
index a126527fd7..d38dec09e9 100644
--- a/tools/skillgen/expected/graphify__skill-codex.md
+++ b/tools/skillgen/expected/graphify__skill-codex.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-copilot.md b/tools/skillgen/expected/graphify__skill-copilot.md
index 2eca2a295f..1f4d24162f 100644
--- a/tools/skillgen/expected/graphify__skill-copilot.md
+++ b/tools/skillgen/expected/graphify__skill-copilot.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-devin.md b/tools/skillgen/expected/graphify__skill-devin.md
index 9bd1f0dc73..ac25fa6b6e 100644
--- a/tools/skillgen/expected/graphify__skill-devin.md
+++ b/tools/skillgen/expected/graphify__skill-devin.md
@@ -208,10 +208,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -221,7 +229,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files)
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
@@ -905,6 +918,10 @@ fi
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skill-droid.md b/tools/skillgen/expected/graphify__skill-droid.md
index b1eeb4925a..df8b4bb67d 100644
--- a/tools/skillgen/expected/graphify__skill-droid.md
+++ b/tools/skillgen/expected/graphify__skill-droid.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-kilo.md b/tools/skillgen/expected/graphify__skill-kilo.md
index 387563de43..cb4c434985 100644
--- a/tools/skillgen/expected/graphify__skill-kilo.md
+++ b/tools/skillgen/expected/graphify__skill-kilo.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-kiro.md b/tools/skillgen/expected/graphify__skill-kiro.md
index 2eca2a295f..1f4d24162f 100644
--- a/tools/skillgen/expected/graphify__skill-kiro.md
+++ b/tools/skillgen/expected/graphify__skill-kiro.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-opencode.md b/tools/skillgen/expected/graphify__skill-opencode.md
index 144378e65a..03baadef09 100644
--- a/tools/skillgen/expected/graphify__skill-opencode.md
+++ b/tools/skillgen/expected/graphify__skill-opencode.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-pi.md b/tools/skillgen/expected/graphify__skill-pi.md
index 2eca2a295f..1f4d24162f 100644
--- a/tools/skillgen/expected/graphify__skill-pi.md
+++ b/tools/skillgen/expected/graphify__skill-pi.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-trae.md b/tools/skillgen/expected/graphify__skill-trae.md
index 36706e3d7c..51273ca71c 100644
--- a/tools/skillgen/expected/graphify__skill-trae.md
+++ b/tools/skillgen/expected/graphify__skill-trae.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-vscode.md b/tools/skillgen/expected/graphify__skill-vscode.md
index 6191a4ca6d..00a2ba94cc 100644
--- a/tools/skillgen/expected/graphify__skill-vscode.md
+++ b/tools/skillgen/expected/graphify__skill-vscode.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md
index 456244a246..64bac5060c 100644
--- a/tools/skillgen/expected/graphify__skill-windows.md
+++ b/tools/skillgen/expected/graphify__skill-windows.md
@@ -205,10 +205,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```powershell
@'
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -218,7 +226,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
print(f'AST: {len(result["nodes"])} nodes, {len(result["edges"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skill.md b/tools/skillgen/expected/graphify__skill.md
index 2eca2a295f..1f4d24162f 100644
--- a/tools/skillgen/expected/graphify__skill.md
+++ b/tools/skillgen/expected/graphify__skill.md
@@ -177,10 +177,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -190,7 +198,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__agents__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__agents__references__update.md b/tools/skillgen/expected/graphify__skills__agents__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__agents__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__agents__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__amp__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__amp__references__update.md b/tools/skillgen/expected/graphify__skills__amp__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__amp__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__amp__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__claude__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__claude__references__update.md b/tools/skillgen/expected/graphify__skills__claude__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__claude__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__claude__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__claw__references__extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__claw__references__update.md b/tools/skillgen/expected/graphify__skills__claw__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__claw__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__claw__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__codex__references__extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__codex__references__update.md b/tools/skillgen/expected/graphify__skills__codex__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__codex__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__codex__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__copilot__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__update.md b/tools/skillgen/expected/graphify__skills__copilot__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__copilot__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__copilot__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__droid__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__droid__references__update.md b/tools/skillgen/expected/graphify__skills__droid__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__droid__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__droid__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__kilo__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__update.md b/tools/skillgen/expected/graphify__skills__kilo__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__kilo__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__kilo__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__kiro__references__extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__update.md b/tools/skillgen/expected/graphify__skills__kiro__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__kiro__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__kiro__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__opencode__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__update.md b/tools/skillgen/expected/graphify__skills__opencode__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__opencode__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__opencode__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md
index 4b278b28d3..0398cfeeee 100644
--- a/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__pi__references__extraction-spec.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__pi__references__update.md b/tools/skillgen/expected/graphify__skills__pi__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__pi__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__pi__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__trae__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__trae__references__update.md b/tools/skillgen/expected/graphify__skills__trae__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__trae__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__trae__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__vscode__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__update.md b/tools/skillgen/expected/graphify__skills__vscode__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__vscode__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__vscode__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md b/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md
+++ b/tools/skillgen/expected/graphify__skills__windows__references__extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/expected/graphify__skills__windows__references__update.md b/tools/skillgen/expected/graphify__skills__windows__references__update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/expected/graphify__skills__windows__references__update.md
+++ b/tools/skillgen/expected/graphify__skills__windows__references__update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/fragments/core/aider.md b/tools/skillgen/fragments/core/aider.md
index 6e459945af..f873f01a60 100644
--- a/tools/skillgen/fragments/core/aider.md
+++ b/tools/skillgen/fragments/core/aider.md
@@ -195,10 +195,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -208,7 +216,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files)
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
@@ -766,6 +779,10 @@ The graph is the map. Your job after the pipeline is to be the guide.
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/fragments/core/core.md b/tools/skillgen/fragments/core/core.md
index c527a12563..364fe9a275 100644
--- a/tools/skillgen/fragments/core/core.md
+++ b/tools/skillgen/fragments/core/core.md
@@ -130,10 +130,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -143,7 +151,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files, cache_root=Path('INPUT_PATH'))
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
diff --git a/tools/skillgen/fragments/core/devin.md b/tools/skillgen/fragments/core/devin.md
index 9bd1f0dc73..ac25fa6b6e 100644
--- a/tools/skillgen/fragments/core/devin.md
+++ b/tools/skillgen/fragments/core/devin.md
@@ -208,10 +208,18 @@ Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is determin
For any code files detected, run AST extraction in parallel with Part B subagents:
+[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.
+
+[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.
+
+[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
+from graphify.qml_safety import require_complete_qml
+from graphify.qt_analysis_state import inspect_qt_analysis
from pathlib import Path
import json
@@ -221,7 +229,12 @@ for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
- result = extract(code_files)
+ scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()
+ scan_root = scan_root.parent if scan_root.is_file() else scan_root
+ qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)
+ result = extract(code_files, root=scan_root, cache_root=scan_root,
+ qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)
+ require_complete_qml(result, code_files, operation='skill AST', root=scan_root)
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
@@ -905,6 +918,10 @@ fi
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/fragments/references/shared/extraction-spec-compact.md b/tools/skillgen/fragments/references/shared/extraction-spec-compact.md
index 4b278b28d3..0398cfeeee 100644
--- a/tools/skillgen/fragments/references/shared/extraction-spec-compact.md
+++ b/tools/skillgen/fragments/references/shared/extraction-spec-compact.md
@@ -14,6 +14,7 @@ Rules:
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports. When adding `calls` edges: source is the caller, target is the callee, never reversed; keep `calls` within one language.
+- [Qt/QML] Preserve source-backed AST bridge IDs, contexts, spans and direction. Do not invent label-based QML/JavaScript/C++ links, synchronous signal delivery or QML `id`/C++ `objectName` equivalence; connections, emissions and meta-object accesses are distinct from calls.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms) and `file_type:"concept"` for named concepts. `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges — indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
@@ -24,6 +25,8 @@ Rules:
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format `{stem}_{entity}` where stem is the full repo-relative path with the extension dropped, every segment joined with `_` (each lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent. `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`. Top-level files use just the filename stem. This must match the AST extractor's ID. Never append chunk or sequence suffixes — IDs must be deterministic from the label alone.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Output exactly this JSON (no other text):
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/fragments/references/shared/extraction-spec.md b/tools/skillgen/fragments/references/shared/extraction-spec.md
index 388df7674f..a8bb52de70 100644
--- a/tools/skillgen/fragments/references/shared/extraction-spec.md
+++ b/tools/skillgen/fragments/references/shared/extraction-spec.md
@@ -18,6 +18,7 @@ Code files: focus on semantic edges AST cannot find (call relationships, shared
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
+[Qt/QML] Preserve source-backed AST bridge facts and their existing IDs, contexts, spans and direction. Do not recreate QML/JavaScript/C++ bridges by matching labels. Declared signal connections, emissions and meta-object accesses are distinct from direct calls; never infer synchronous delivery or equate QML `id` with `objectName`.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
@@ -60,6 +61,8 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the **full repo-relative path with the extension dropped**, every path segment kept and joined with `_` (each segment lowercased with non-alphanumeric chars replaced by `_`), and entity is the symbol name similarly normalized. Use every directory level, not just the immediate parent — this keeps same-named files in different directories distinct. Examples: `src/auth/session.py` + `ValidateToken` → `src_auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `lib_utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`; `docs/v1/api/README.md` + `getUser` → `docs_v1_api_readme_getuser`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or only the immediate parent (e.g., `auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project built under the old immediate-parent format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
+[Qt/QML] Reuse existing Qt/QML AST IDs verbatim when referencing those facts. Their component/member scope and exact source identity are already encoded; never replace them with a label-only ID or create a duplicate fact to satisfy the generic ID guidance above.
+
Generate the extraction JSON matching this schema exactly:
{"nodes":[{"id":"auth_session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":""}],"input_tokens":0,"output_tokens":0}
diff --git a/tools/skillgen/fragments/references/shared/update.md b/tools/skillgen/fragments/references/shared/update.md
index 3632fd4126..a917e2224b 100644
--- a/tools/skillgen/fragments/references/shared/update.md
+++ b/tools/skillgen/fragments/references/shared/update.md
@@ -6,6 +6,10 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
+[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.
+
+[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.
+
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py
index 6c30a08475..b90cd4a7f1 100644
--- a/tools/skillgen/gen.py
+++ b/tools/skillgen/gen.py
@@ -1180,6 +1180,32 @@ def _is_watch_injection_fix_line(line: str) -> bool:
)
+def _is_qt_qml_source_guidance_line(line: str) -> bool:
+ """Allow only the reviewed Qt source runbook additions, never a prefix class.
+
+ Keep the frozen v8 comparison intact. Exact prose and the complete AST root/
+ pre-publication gate footprint are listed here; unrelated Qt-prefixed advice
+ or arbitrary extraction edits must still fail the monolith roundtrip.
+ """
+ return line.strip() in {
+ "[Qt/QML] Install the optional `graphifyy[qml]` extra in the recorded interpreter when the accepted corpus contains QML or `.qmltypes`. Analysis does not require a Qt SDK and never executes project code or build tools.",
+ "[Qt/QML] Preserve scoped source identities, evidence and unresolved statuses. Bindings, signal emissions, declared connections and meta-object accesses have different meanings; an emission does not prove a synchronous slot call. QML `id` does not establish C++ `objectName` lookup.",
+ "[Qt/QML] The literal CMake/qmake/QRC/type-description subset and its limits are documented in `docs/qt-qml/README.md` when available. Conditional builds, dynamic targets and unsupported syntax cannot become guessed providers. A failed integrity gate preserves prior output; do not force past it.",
+ "[Qt/QML] For a Qt/QML corpus, run `graphify update` in the recorded interpreter with the trusted `graphify-out/.graphify_root` scan root, then stop this manual merge procedure. The production updater refreshes unchanged consumers after C++/metadata changes, checks parser/import/ignore configuration and commits its analysis stamp after successful publication. Include `.qml`, `.qmltypes`, `qmldir`, `CMakeLists.txt`, `.cmake`, `.pro`, `.pri`, `.qrc` and Qt C++ sources when identifying this branch.",
+ "[Qt/QML] Preserve unsupported/partial diagnostics and prior graph, manifest and report output. Never use force or an empty extraction to bypass a Qt integrity failure. Configured import roots select accepted providers; they do not authorize additional source discovery.",
+ "from graphify.qml_safety import require_complete_qml",
+ "from graphify.qt_analysis_state import inspect_qt_analysis",
+ "scan_root = Path(Path('graphify-out/.graphify_root').read_text(encoding='utf-8').strip()).resolve()",
+ "scan_root = scan_root.parent if scan_root.is_file() else scan_root",
+ "qt_state = inspect_qt_analysis(scan_root, Path('graphify-out'), code_files)",
+ "result = extract(code_files, root=scan_root, cache_root=scan_root,",
+ "qml_import_roots=qt_state.import_roots, refresh_native=qt_state.has_qt)",
+ "require_complete_qml(result, code_files, operation='skill AST', root=scan_root)",
+ # The replaced v8 AST call. Split-core cache call is not a monolith line.
+ "result = extract(code_files)",
+ }
+
+
# Every line that may differ between a rendered monolith and its pristine v8
# baseline. Each predicate documents one sanctioned change-class; a blank line is
# allowed because the multi-line fix blocks insert spacing. Anything else failing
@@ -1203,6 +1229,7 @@ def _is_watch_injection_fix_line(line: str) -> bool:
_is_community_label_export_fix_line,
_is_step1_root_marker_fix_line,
_is_watch_injection_fix_line,
+ _is_qt_qml_source_guidance_line,
)
diff --git a/uv.lock b/uv.lock
index 0e90baef91..36127389da 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1238,6 +1238,9 @@ postgres = [
{ name = "psycopg", extra = ["binary"] },
{ name = "tree-sitter-sql" },
]
+qml = [
+ { name = "tree-sitter-language-pack" },
+]
r = [
{ name = "tree-sitter-language-pack" },
]
@@ -1368,6 +1371,7 @@ requires-dist = [
{ name = "tree-sitter-kotlin", specifier = ">=1.0,<2.0" },
{ name = "tree-sitter-language-pack", marker = "extra == 'all'", specifier = "==0.11.0" },
{ name = "tree-sitter-language-pack", marker = "extra == 'erlang'", specifier = "==0.11.0" },
+ { name = "tree-sitter-language-pack", marker = "extra == 'qml'", specifier = "==0.11.0" },
{ name = "tree-sitter-language-pack", marker = "extra == 'r'", specifier = "==0.11.0" },
{ name = "tree-sitter-lua", specifier = ">=0.2,<0.6" },
{ name = "tree-sitter-objc", specifier = ">=3.0,<4.0" },
@@ -1397,7 +1401,7 @@ requires-dist = [
{ name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.7.4" },
{ name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.7.4" },
]
-provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "robot", "vbnet", "r", "erlang", "solidity", "all"]
+provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "robot", "vbnet", "r", "qml", "erlang", "solidity", "all"]
[package.metadata.requires-dev]
dev = [