This is an automated email from the ASF dual-hosted git repository.

tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git


The following commit(s) were added to refs/heads/main by this push:
     new eed79b04 [STUBGEN] Classify native layouts from reflected byte facts 
(#730)
eed79b04 is described below

commit eed79b047b56afaccad87a5d29b2ead2f5aad188
Author: Linzhang Li <[email protected]>
AuthorDate: Thu Sep 3 19:50:43 2026 -0400

    [STUBGEN] Classify native layouts from reflected byte facts (#730)
    
    # [STUBGEN] Classify native layouts from reflected byte facts
    
    ## Summary
    
    Add a language-neutral classifier that decides, for every registered
    object type, whether its native layout can be reproduced from reflection
    (`complete`) or not (`opaque`), with the byte evidence behind each
    verdict. `tvm-ffi-stubgen --coverage-out <json>` writes the verdicts as
    a report. No generator is attached.
    
    ## Motivation
    
    A native-struct backend can hold an object by value only when it can
    reproduce the C++ layout byte for byte. The registry already publishes
    what that needs: each type's own `sizeof` (#720) and every field's size,
    alignment and offset. A wrong `complete` is a struct with the wrong
    layout, a wrong `opaque` only costs a C ABI call per field, so the
    classifier deserves a review of its own before any generator builds on
    it.
    
    ## Changes
    
    - `stub/layout.py` (new). One criterion: the reflected fields must fill
    `[parent.total_size, total_size)` exactly, allowing only the gaps
    alignment forces. Two prerequisites: the type has metadata of its own
    (`layout-unknown`) and its parent is complete (`parent-opaque`).
    Unexplained bytes are `uncovered-bytes`; a field starting before the
    cursor is `field-overlap`. Polymorphism is not a rule: a vptr makes the
    fields fall short of `sizeof` on its own. Two target-language rules are
    injection points: `field_renderable` and `forced_opaque`, the latter
    only demoting a type that would otherwise be complete.
    - `stub/cli.py`, `stub/utils.py`: `--coverage-out <json>`, usable
    without PATH arguments. The report fixes the language-neutral keys; a
    generator adds its own under a per-type `"target"` key.
    - `testing.cc`: `TestCxxClassHiddenField` (an unreflected member) and
    `TestCxxClassPolymorphic` (a vptr), so both failure paths run in CI.
    
    ## Testing
    
    `tests/python/test_stub_layout.py`: every verdict path on synthetic byte
    facts, then the real registry. The `TestCxxClass*` chain is complete
    with padding `[36, 40)`, `[52, 56)`, `[73, 80)`;
    `TestCxxClassHiddenField` reports `[32, 40)` uncovered;
    `TestCxxClassPolymorphic` reports `sizeof` 40 against fields ending at
    32; `ffi.Function` is `layout-unknown`; `ffi.Module` is opaque like the
    polymorphic fixture. The CLI writes the report with `--coverage-out`
    alone.
    
    ---------
    
    Signed-off-by: yuchuan <[email protected]>
---
 python/tvm_ffi/stub/cli.py       |  24 ++-
 python/tvm_ffi/stub/layout.py    | 361 ++++++++++++++++++++++++++++++++++++++
 python/tvm_ffi/stub/utils.py     |   5 +-
 src/ffi/testing/testing.cc       |  29 ++++
 tests/python/test_stub_layout.py | 362 +++++++++++++++++++++++++++++++++++++++
 5 files changed, 779 insertions(+), 2 deletions(-)

diff --git a/python/tvm_ffi/stub/cli.py b/python/tvm_ffi/stub/cli.py
index 3dfc8151..a5e0ba39 100644
--- a/python/tvm_ffi/stub/cli.py
+++ b/python/tvm_ffi/stub/cli.py
@@ -29,6 +29,7 @@ from typing import TYPE_CHECKING
 from . import consts as C
 from .file_utils import FileInfo, collect_files, syntax_for
 from .generator import generator_names, get_generator
+from .layout import classify, write_coverage_report
 from .lib_state import (
     collect_global_funcs,
     collect_type_keys,
@@ -111,6 +112,15 @@ def __main__() -> int:
     if opt.init and generated_prefixes:
         assert init_path is not None
         generator.finalize_init(init_path, generated_prefixes)
+
+    # Write the native-layout coverage report, if requested.
+    if opt.coverage_out is not None:
+        infos = {
+            type_key: object_info_from_type_key(type_key)
+            for type_keys in collect_type_keys().values()
+            for type_key in type_keys
+        }
+        write_coverage_report(Path(opt.coverage_out), classify(infos))
     del dlls
     return 0
 
@@ -380,6 +390,17 @@ def _parse_args() -> Options:
             "without modifying any files."
         ),
     )
+    parser.add_argument(
+        "--coverage-out",
+        type=str,
+        default=None,
+        metavar="JSON",
+        help=(
+            "Write a JSON report classifying every registered object type by 
whether "
+            "its native memory layout can be reproduced from reflection 
(complete) or "
+            "not (opaque), with the byte evidence. May be used without PATH 
arguments."
+        ),
+    )
     args = parser.parse_args()
 
     init_flags = [args.init_pypkg, args.init_lib, args.init_prefix]
@@ -393,7 +414,7 @@ def _parse_args() -> Options:
             prefix=args.init_prefix,
         )
 
-    if not args.files:
+    if not args.files and args.coverage_out is None:
         parser.print_help()
         sys.exit(1)
 
@@ -406,6 +427,7 @@ def _parse_args() -> Options:
         verbose=args.verbose,
         dry_run=args.dry_run,
         target=args.target,
+        coverage_out=args.coverage_out,
     )
 
 
diff --git a/python/tvm_ffi/stub/layout.py b/python/tvm_ffi/stub/layout.py
new file mode 100644
index 00000000..798676a4
--- /dev/null
+++ b/python/tvm_ffi/stub/layout.py
@@ -0,0 +1,361 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Native-layout classification for ``tvm-ffi-stubgen``.
+
+A generator for a language with native structs can hold a reflected object *by
+value* only when it can reproduce the object's memory layout byte for byte. The
+registry publishes what the compiler computed: each type's own ``sizeof``
+(:attr:`.ObjectInfo.total_size`) and every reflected field's size, alignment 
and
+offset. One criterion decides the question:
+
+    The reflected fields must fill ``[parent.total_size, total_size)`` exactly,
+    allowing only the gaps alignment forces.
+
+The cursor starts at the parent's size; each field, by ascending offset, must
+start at ``align_up(cursor, field.alignment)``; at the end
+``align_up(cursor, alignment)`` must equal ``total_size``, where ``alignment`` 
is
+the largest alignment along the chain, the header's included. Two facts must
+exist before the criterion can be evaluated: the type needs metadata of its own
+(a type without an ``ObjectDef`` inherits its parent's entry, whose size says
+nothing about it) and its parent must itself be complete. Nothing else is a
+rule: a class with a vptr, for instance, reports offsets relative to the
+``TVMFFIObject`` header while its ``sizeof`` is absolute, so its fields can 
never
+fill the region.
+
+``opaque`` is a normal, final verdict: a generator still emits a wrapper and
+reaches the fields through the C ABI. Two target-language rules are injected by
+the caller instead of living here: a field-renderability predicate, and a set 
of
+semantic vetoes for types that must never be allocated outside their runtime.
+
+The coverage report fixes the language-neutral keys of each entry; a generator
+adds its own facts under a per-type ``"target"`` key.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import json
+from typing import TYPE_CHECKING, Any, Callable, Literal
+
+from typing_extensions import TypeAlias
+
+if TYPE_CHECKING:
+    from collections.abc import Mapping
+    from collections.abc import Set as AbstractSet
+    from pathlib import Path
+
+    from .utils import NamedTypeSchema, ObjectInfo
+
+#: Alignment of the ``TVMFFIObject`` header (a 64-bit reference count); no 
object is aligned less.
+OBJECT_HEADER_ALIGNMENT = 8
+
+OpaqueReason: TypeAlias = Literal[
+    "layout-unknown",
+    "parent-opaque",
+    "field-overlap",
+    "uncovered-bytes",
+    "unrenderable-field",
+    "by-directive",
+]
+"""Why a type is opaque.
+
+- ``layout-unknown``: no metadata of its own, or a field without byte facts.
+- ``parent-opaque``: the parent is opaque, so the region to fill has no
+  trustworthy start.
+- ``field-overlap``: a field starts before the cursor (e.g. inside the parent's
+  tail padding, which the Itanium ABI may reuse) or ends past ``total_size``.
+- ``uncovered-bytes``: a gap alignment does not explain (an unreflected member,
+  a vptr, ...).
+- ``unrenderable-field``: the caller's predicate rejected a field.
+- ``by-directive``: the caller vetoed a type whose layout is reproducible.
+"""
+
+
+def align_up(value: int, alignment: int) -> int:
+    """Round ``value`` up to the next multiple of ``alignment``."""
+    return (value + alignment - 1) // alignment * alignment
+
+
[email protected](frozen=True)
+class ByteRange:
+    """A half-open byte interval ``[start, end)`` inside an object."""
+
+    start: int
+    end: int
+
+    def __str__(self) -> str:
+        return f"[{self.start}, {self.end})"
+
+    def to_json_obj(self) -> list[int]:
+        """Render as ``[start, end]``."""
+        return [self.start, self.end]
+
+
[email protected](frozen=True)
+class FieldBytes:
+    """The byte facts of one reflected field."""
+
+    name: str
+    offset: int
+    size: int
+    alignment: int
+
+    @property
+    def end(self) -> int:
+        """One past the last byte of the field."""
+        return self.offset + self.size
+
+    def to_json_obj(self) -> dict[str, Any]:
+        """Render as a JSON object."""
+        return dataclasses.asdict(self)
+
+
[email protected]
+class Verdict:
+    """The layout verdict of one type, with the byte evidence behind it."""
+
+    type_key: str
+    verdict: Literal["complete", "opaque"]
+    reason: OpaqueReason | None
+    """``None`` for a complete type; otherwise which rule demoted it."""
+    detail: str
+    """Human-readable explanation, quoting the byte evidence."""
+    parent_type_key: str | None
+    ancestors: list[str]
+    total_size: int | None
+    is_final: bool | None
+    alignment: int | None = None
+    """Natural alignment of the object, derived along the chain (drives the 
tail check)."""
+    own_bytes: ByteRange | None = None
+    """The region this type's own fields must fill: ``[parent.total_size, 
total_size)``."""
+    fields: list[FieldBytes] = dataclasses.field(default_factory=list)
+    """This type's own reflected fields by ascending offset; empty when the 
layout is unknown."""
+    uncovered: list[ByteRange] = dataclasses.field(default_factory=list)
+    """Bytes inside ``own_bytes`` that neither a field nor an alignment gap 
accounts for."""
+    padding: list[ByteRange] = dataclasses.field(default_factory=list)
+    """Alignment-forced gaps, recorded so a reviewer can check them against 
the C++ declaration."""
+
+    @property
+    def is_complete(self) -> bool:
+        """Whether the native layout is reproducible from the reflected 
fields."""
+        return self.verdict == "complete"
+
+    def to_json_obj(self) -> dict[str, Any]:
+        """Render the language-neutral report entry for this type."""
+        return {
+            "verdict": self.verdict,
+            "reason": self.reason,
+            "detail": self.detail,
+            "parent": self.parent_type_key,
+            "ancestors": list(self.ancestors),
+            "total_size": self.total_size,
+            "is_final": self.is_final,
+            "own_bytes": None if self.own_bytes is None else 
self.own_bytes.to_json_obj(),
+            "fields": [f.to_json_obj() for f in self.fields],
+            "uncovered": [r.to_json_obj() for r in self.uncovered],
+            "padding": [r.to_json_obj() for r in self.padding],
+        }
+
+
+def classify(
+    infos: Mapping[str, ObjectInfo],
+    *,
+    forced_opaque: AbstractSet[str] = frozenset(),
+    field_renderable: Callable[[NamedTypeSchema], bool] | None = None,
+) -> dict[str, Verdict]:
+    """Classify every type in ``infos``, parents before children.
+
+    Parameters
+    ----------
+    infos
+        The types to classify, keyed by type key, in any order. Every ancestor
+        of a type must be present as well.
+    forced_opaque
+        Type keys vetoed by the caller (semantic blockers such as interned
+        identities). The veto only demotes a type that would otherwise be
+        complete; a type that is opaque for a layout reason keeps that reason.
+    field_renderable
+        Predicate deciding whether a reflected field has a native mirror in the
+        target language. A rejected field demotes its type to opaque with
+        reason ``unrenderable-field``. ``None`` accepts every field.
+
+    """
+    verdicts: dict[str, Verdict] = {}
+
+    def _classify(type_key: str) -> Verdict:
+        if type_key in verdicts:
+            return verdicts[type_key]
+        if type_key not in infos:
+            raise KeyError(f"Ancestor {type_key!r} is not among the types to 
classify")
+        info = infos[type_key]
+        parent = None if info.parent_type_key is None else 
_classify(info.parent_type_key)
+        verdicts[type_key] = _classify_one(info, parent, forced_opaque, 
field_renderable)
+        return verdicts[type_key]
+
+    for type_key in infos:
+        _classify(type_key)
+    return verdicts
+
+
+def _classify_one(
+    info: ObjectInfo,
+    parent: Verdict | None,
+    forced_opaque: AbstractSet[str],
+    field_renderable: Callable[[NamedTypeSchema], bool] | None,
+) -> Verdict:
+    assert info.type_key is not None, "cannot classify an ObjectInfo without a 
type key"
+    verdict = Verdict(
+        type_key=info.type_key,
+        verdict="opaque",
+        reason=None,
+        detail="",
+        parent_type_key=info.parent_type_key,
+        ancestors=list(info.ancestors),
+        total_size=info.total_size,
+        is_final=info.is_final,
+    )
+    outcome = _prove_layout(info, parent, verdict)
+    if outcome is None:
+        outcome = _apply_target_rules(info, forced_opaque, field_renderable)
+    if outcome is not None:
+        verdict.reason, verdict.detail = outcome
+        return verdict
+    verdict.verdict = "complete"
+    if parent is None:
+        verdict.detail = f"object header [0, {info.total_size}): owned by the 
C ABI"
+    else:
+        verdict.detail = f"reflected fields fill {verdict.own_bytes} exactly"
+        if verdict.padding:
+            gaps = ", ".join(str(r) for r in verdict.padding)
+            verdict.detail += f" (alignment padding {gaps})"
+    return verdict
+
+
+def _prove_layout(
+    info: ObjectInfo, parent: Verdict | None, verdict: Verdict
+) -> tuple[OpaqueReason, str] | None:
+    """Check the prerequisites and the fill criterion, recording the evidence 
on ``verdict``.
+
+    Returns ``None`` when the layout is reproducible, else the reason it is 
not.
+    """
+    # Nothing to check without the type's own size and every field's byte 
facts.
+    if info.total_size is None:
+        return "layout-unknown", "no metadata of its own: total_size is 
unknown"
+    fields = _field_bytes(info)
+    if isinstance(fields, str):
+        return "layout-unknown", fields
+    verdict.fields = fields
+
+    # The parent's size is where this type's own bytes start, so the parent 
must be complete.
+    if parent is None:
+        # The root is the `TVMFFIObject` header: C ABI bytes, nothing to fill.
+        verdict.alignment = OBJECT_HEADER_ALIGNMENT
+        verdict.own_bytes = ByteRange(info.total_size, info.total_size)
+        return None
+    if not parent.is_complete:
+        return "parent-opaque", f"parent {parent.type_key!r} is opaque 
({parent.reason})"
+    assert parent.total_size is not None and parent.alignment is not None
+
+    # Fields must fill [parent.total_size, total_size) exactly.
+    verdict.own_bytes = ByteRange(parent.total_size, info.total_size)
+    verdict.alignment = max([parent.alignment, *(f.alignment for f in fields)])
+    return _fill(verdict)
+
+
+def _field_bytes(info: ObjectInfo) -> list[FieldBytes] | str:
+    """Return the type's own fields by ascending offset, or why their bytes 
are unknown."""
+    fields: list[FieldBytes] = []
+    for f in info.fields:
+        if f.size is None or f.alignment is None or f.offset is None:
+            return f"field {f.name!r} carries no native layout facts"
+        fields.append(FieldBytes(name=f.name, offset=f.offset, size=f.size, 
alignment=f.alignment))
+    return sorted(fields, key=lambda f: f.offset)
+
+
+def _fill(verdict: Verdict) -> tuple[OpaqueReason, str] | None:
+    """Walk ``verdict.fields`` over ``verdict.own_bytes``, sorting every gap 
into padding or hole.
+
+    Returns why the fill fails (the first overlap, else the uncovered bytes), 
or ``None``.
+    """
+    assert verdict.own_bytes is not None and verdict.alignment is not None
+    end = verdict.own_bytes.end
+    overlap: str | None = None
+    cursor = verdict.own_bytes.start
+    for field in verdict.fields:
+        if field.offset < cursor:
+            where = f"[{field.offset}, {field.end}) starts before byte 
{cursor}"
+            overlap = overlap or f"field {field.name!r} at {where}"
+            cursor = max(cursor, field.end)
+            continue
+        _record_gap(verdict, cursor, align_up(cursor, field.alignment), 
field.offset)
+        cursor = field.end
+    if cursor > end:
+        overlap = overlap or f"fields extend to byte {cursor}, past total_size 
{end}"
+    else:
+        _record_gap(verdict, cursor, align_up(cursor, verdict.alignment), end)
+    if overlap is not None:
+        return "field-overlap", overlap
+    if verdict.uncovered:
+        ranges = ", ".join(str(r) for r in verdict.uncovered)
+        return "uncovered-bytes", (
+            f"bytes {ranges} of {verdict.own_bytes} are not accounted for by 
reflected fields"
+        )
+    return None
+
+
+def _record_gap(verdict: Verdict, cursor: int, expected: int, actual: int) -> 
None:
+    """Classify the gap between ``cursor`` and the next boundary ``actual``.
+
+    ``expected`` is where alignment alone would put that boundary: a gap that
+    ends exactly there is padding, any other gap is a hole.
+    """
+    if expected != actual:
+        verdict.uncovered.append(ByteRange(cursor, actual))
+    elif expected != cursor:
+        verdict.padding.append(ByteRange(cursor, expected))
+
+
+def _apply_target_rules(
+    info: ObjectInfo,
+    forced_opaque: AbstractSet[str],
+    field_renderable: Callable[[NamedTypeSchema], bool] | None,
+) -> tuple[OpaqueReason, str] | None:
+    """Apply the caller-injected, target-language rules to a type whose layout 
is proven."""
+    if field_renderable is not None:
+        for field in info.fields:
+            if not field_renderable(field):
+                return "unrenderable-field", (
+                    f"field {field.name!r} ({field.repr()}) has no native 
mirror"
+                )
+    if info.type_key in forced_opaque:
+        return "by-directive", "vetoed by directive although the layout is 
reproducible"
+    return None
+
+
+def coverage_report(verdicts: Mapping[str, Verdict]) -> dict[str, Any]:
+    """Build the JSON-serialisable coverage report: one entry per type key, 
sorted.
+
+    Each entry holds the language-neutral keys rendered by
+    :meth:`Verdict.to_json_obj`; a generator may add a ``"target"`` key with
+    its own facts.
+    """
+    return {key: verdicts[key].to_json_obj() for key in sorted(verdicts)}
+
+
+def write_coverage_report(path: Path, verdicts: Mapping[str, Verdict]) -> None:
+    """Write :func:`coverage_report` to ``path`` as indented JSON."""
+    path.write_text(json.dumps(coverage_report(verdicts), indent=2) + "\n", 
encoding="utf-8")
diff --git a/python/tvm_ffi/stub/utils.py b/python/tvm_ffi/stub/utils.py
index a053758e..7f5ea625 100644
--- a/python/tvm_ffi/stub/utils.py
+++ b/python/tvm_ffi/stub/utils.py
@@ -85,6 +85,8 @@ class Options:
     dry_run: bool = False
     target: str = "python"
     """Code generator target to use."""
+    coverage_out: str | None = None
+    """Path of the native-layout coverage report (JSON) to write, if 
requested."""
 
 
 @dataclasses.dataclass(init=False)
@@ -248,8 +250,9 @@ class ObjectInfo:
                         )
                     )
 
+        # `fields` is None while a `py_class` registration is still pending.
         return ObjectInfo(
-            fields=[NamedTypeSchema.from_type_field(field) for field in 
type_info.fields],
+            fields=[NamedTypeSchema.from_type_field(field) for field in 
type_info.fields or []],
             methods=[
                 FuncInfo(
                     schema=NamedTypeSchema(
diff --git a/src/ffi/testing/testing.cc b/src/ffi/testing/testing.cc
index 21b87b46..742ca9d0 100644
--- a/src/ffi/testing/testing.cc
+++ b/src/ffi/testing/testing.cc
@@ -206,6 +206,29 @@ class TestCxxClassDerivedDerived : public 
TestCxxClassDerived {
                               TestCxxClassDerived);
 };
 
+// Fixtures for the stub layout classifier: an unreflected member, and a vptr.
+class TestCxxClassHiddenField : public Object {
+ public:
+  int64_t v_i64 = 0;
+  int64_t hidden_i64 = 0;  // deliberately not reflected
+  int32_t v_i32 = 0;
+
+  static constexpr bool _type_mutable = true;
+  TVM_FFI_DECLARE_OBJECT_INFO_FINAL("testing.TestCxxClassHiddenField", 
TestCxxClassHiddenField,
+                                    Object);
+};
+
+class TestCxxClassPolymorphic : public Object {
+ public:
+  int64_t v_i64 = 0;
+
+  virtual ~TestCxxClassPolymorphic() = default;
+
+  static constexpr bool _type_mutable = true;
+  TVM_FFI_DECLARE_OBJECT_INFO_FINAL("testing.TestCxxClassPolymorphic", 
TestCxxClassPolymorphic,
+                                    Object);
+};
+
 class TestCxxEnumHolderObj : public Object {
  public:
   TestCxxIntEnum priority;
@@ -498,6 +521,12 @@ TVM_FFI_STATIC_INIT_BLOCK() {
       .def_rw("v_str", &TestCxxClassDerivedDerived::v_str, 
refl::default_value(String("default")))
       .def_rw("v_bool", &TestCxxClassDerivedDerived::v_bool);
 
+  refl::ObjectDef<TestCxxClassHiddenField>()
+      .def_rw("v_i64", &TestCxxClassHiddenField::v_i64)
+      .def_rw("v_i32", &TestCxxClassHiddenField::v_i32);
+
+  refl::ObjectDef<TestCxxClassPolymorphic>().def_rw("v_i64", 
&TestCxxClassPolymorphic::v_i64);
+
   refl::ObjectDef<TestCxxEnumHolderObj>()
       .def_rw("priority", &TestCxxEnumHolderObj::priority)
       .def_rw("opcode", &TestCxxEnumHolderObj::opcode);
diff --git a/tests/python/test_stub_layout.py b/tests/python/test_stub_layout.py
new file mode 100644
index 00000000..e0a6edcb
--- /dev/null
+++ b/tests/python/test_stub_layout.py
@@ -0,0 +1,362 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for the native-layout classifier behind ``tvm-ffi-stubgen 
--coverage-out``."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+import tvm_ffi.stub.cli as stub_cli
+import tvm_ffi.testing  # noqa: F401  (loads the `testing.*` fixture types)
+from tvm_ffi import core
+from tvm_ffi.core import TypeSchema
+from tvm_ffi.stub.layout import (
+    OBJECT_HEADER_ALIGNMENT,
+    ByteRange,
+    FieldBytes,
+    Verdict,
+    classify,
+    coverage_report,
+    write_coverage_report,
+)
+from tvm_ffi.stub.lib_state import collect_type_keys, object_info_from_type_key
+from tvm_ffi.stub.utils import NamedTypeSchema, ObjectInfo
+
+HEADER = 24  # sizeof(TVMFFIObject)
+
+
+# ---------------------------------------------------------------------------
+# Synthetic fixtures: the criterion is a pure function of the byte facts.
+# ---------------------------------------------------------------------------
+
+
+def _field(
+    name: str, offset: int | None, size: int | None, alignment: int | None = 
None
+) -> NamedTypeSchema:
+    if alignment is None:
+        alignment = size
+    return NamedTypeSchema(name, TypeSchema("int"), size=size, 
alignment=alignment, offset=offset)
+
+
+_ANCESTORS: dict[str, list[str]] = {}
+
+
+def _info(
+    type_key: str,
+    *,
+    parent: str | None = None,
+    total_size: int | None,
+    fields: tuple[NamedTypeSchema, ...] = (),
+    is_final: bool | None = None,
+) -> ObjectInfo:
+    ancestors = [] if parent is None else [*_ANCESTORS[parent], parent]
+    _ANCESTORS[type_key] = ancestors
+    return ObjectInfo(
+        fields=list(fields),
+        methods=[],
+        type_key=type_key,
+        parent_type_key=parent,
+        ancestors=ancestors,
+        total_size=total_size,
+        is_final=is_final,
+    )
+
+
+ROOT = _info("ffi.Object", total_size=HEADER)
+# int64 @24, int32 @32, tail padding [36, 40): the shape of 
`testing.TestCxxClassBase`.
+BASE = _info(
+    "t.Base",
+    parent="ffi.Object",
+    total_size=40,
+    fields=(_field("v_i64", 24, 8), _field("v_i32", 32, 4)),
+    is_final=False,
+)
+
+
+def _ranges(ranges: list[ByteRange]) -> list[tuple[int, int]]:
+    return [(r.start, r.end) for r in ranges]
+
+
+def test_root_is_the_object_header() -> None:
+    """The root of the hierarchy is the C ABI header: complete, nothing to 
fill."""
+    verdict = classify({"ffi.Object": ROOT})["ffi.Object"]
+    assert verdict.is_complete
+    assert verdict.reason is None
+    assert verdict.own_bytes == ByteRange(HEADER, HEADER)
+    assert verdict.alignment == OBJECT_HEADER_ALIGNMENT
+    assert "object header" in verdict.detail
+
+
+def test_complete_with_alignment_padding() -> None:
+    verdict = classify({"ffi.Object": ROOT, "t.Base": BASE})["t.Base"]
+    assert verdict.is_complete
+    assert verdict.reason is None
+    assert verdict.own_bytes == ByteRange(HEADER, 40)
+    assert verdict.alignment == 8
+    assert verdict.fields == [FieldBytes("v_i64", 24, 8, 8), 
FieldBytes("v_i32", 32, 4, 4)]
+    assert _ranges(verdict.padding) == [(36, 40)]
+    assert verdict.uncovered == []
+    assert "[24, 40)" in verdict.detail and "[36, 40)" in verdict.detail
+
+
+def test_fields_are_visited_by_offset_not_declaration_order() -> None:
+    info = _info(
+        "t.Shuffled",
+        parent="ffi.Object",
+        total_size=40,
+        fields=(_field("late", 32, 8), _field("early", 24, 8)),
+    )
+    verdict = classify({"ffi.Object": ROOT, "t.Shuffled": info})["t.Shuffled"]
+    assert verdict.is_complete
+    assert [f.name for f in verdict.fields] == ["early", "late"]
+
+
+def test_uncovered_gap_in_the_middle() -> None:
+    """A member the registry never saw: [32, 40) is neither a field nor 
alignment."""
+    info = _info(
+        "t.Hidden",
+        parent="ffi.Object",
+        total_size=48,
+        fields=(_field("v_i64", 24, 8), _field("v_i32", 40, 4)),
+    )
+    verdict = classify({"ffi.Object": ROOT, "t.Hidden": info})["t.Hidden"]
+    assert verdict.verdict == "opaque"
+    assert verdict.reason == "uncovered-bytes"
+    assert _ranges(verdict.uncovered) == [(32, 40)]
+    assert _ranges(verdict.padding) == [(44, 48)]  # the tail is still 
explained by alignment
+    assert "[32, 40)" in verdict.detail
+
+
+def test_uncovered_tail() -> None:
+    """The polymorphic shape: offsets are header-relative, `sizeof` is 
absolute."""
+    info = _info("t.Poly", parent="ffi.Object", total_size=40, 
fields=(_field("v_i64", 24, 8),))
+    verdict = classify({"ffi.Object": ROOT, "t.Poly": info})["t.Poly"]
+    assert verdict.reason == "uncovered-bytes"
+    assert _ranges(verdict.uncovered) == [(32, 40)]
+    assert verdict.padding == []
+
+
+def test_tail_gap_is_padding_only_when_alignment_forces_it() -> None:
+    padded = _info("t.Pad", parent="ffi.Object", total_size=32, 
fields=(_field("v_i8", 24, 1),))
+    hole = _info("t.Hole", parent="ffi.Object", total_size=40, 
fields=(_field("v_i8", 24, 1),))
+    verdicts = classify({"ffi.Object": ROOT, "t.Pad": padded, "t.Hole": hole})
+    assert verdicts["t.Pad"].is_complete
+    assert _ranges(verdicts["t.Pad"].padding) == [(25, 32)]
+    assert verdicts["t.Hole"].reason == "uncovered-bytes"
+    assert _ranges(verdicts["t.Hole"].uncovered) == [(25, 40)]
+
+
+def test_alignment_propagates_along_the_chain() -> None:
+    base = _info("t.A", parent="ffi.Object", total_size=32, 
fields=(_field("a", 24, 8),))
+    wide = _info("t.B", parent="t.A", total_size=48, fields=(_field("b", 32, 
16, 16),))
+    leaf = _info("t.C", parent="t.B", total_size=64, fields=(_field("c", 48, 
4),))
+    verdicts = classify({"ffi.Object": ROOT, "t.A": base, "t.B": wide, "t.C": 
leaf})
+    assert [verdicts[k].alignment for k in ("t.A", "t.B", "t.C")] == [8, 16, 
16]
+    assert verdicts["t.C"].is_complete
+    assert _ranges(verdicts["t.C"].padding) == [(52, 64)]  # the 16-byte 
alignment explains the tail
+
+
+def test_no_own_metadata_is_layout_unknown_and_propagates() -> None:
+    inherited = _info("t.NoMeta", parent="ffi.Object", total_size=None)
+    child = _info("t.Child", parent="t.NoMeta", total_size=48, 
fields=(_field("x", 40, 8),))
+    verdicts = classify({"ffi.Object": ROOT, "t.NoMeta": inherited, "t.Child": 
child})
+    assert verdicts["t.NoMeta"].reason == "layout-unknown"
+    assert verdicts["t.NoMeta"].own_bytes is None
+    assert verdicts["t.Child"].reason == "parent-opaque"
+    assert "'t.NoMeta'" in verdicts["t.Child"].detail
+    assert "layout-unknown" in verdicts["t.Child"].detail
+    # The child's own byte facts are still recorded as evidence.
+    assert verdicts["t.Child"].fields == [FieldBytes("x", 40, 8, 8)]
+
+
+def test_field_without_byte_facts_is_layout_unknown() -> None:
+    info = _info("t.NoFacts", parent="ffi.Object", total_size=32, 
fields=(_field("x", None, None),))
+    verdict = classify({"ffi.Object": ROOT, "t.NoFacts": info})["t.NoFacts"]
+    assert verdict.reason == "layout-unknown"
+    assert "'x'" in verdict.detail
+
+
+def test_field_overlap() -> None:
+    """A field placed in the parent's tail padding (Itanium ABI) cannot be 
nested by value."""
+    reuse = _info("t.TailReuse", parent="t.Base", total_size=40, 
fields=(_field("v_tail", 36, 4),))
+    overflow = _info("t.Overflow", parent="ffi.Object", total_size=24, 
fields=(_field("a", 24, 8),))
+    verdicts = classify(
+        {"ffi.Object": ROOT, "t.Base": BASE, "t.TailReuse": reuse, 
"t.Overflow": overflow}
+    )
+    assert verdicts["t.TailReuse"].reason == "field-overlap"
+    assert "'v_tail' at [36, 40) starts before byte 40" in 
verdicts["t.TailReuse"].detail
+    assert verdicts["t.Overflow"].reason == "field-overlap"
+    assert "past total_size 24" in verdicts["t.Overflow"].detail
+
+
+def test_forced_opaque_only_demotes_a_complete_type() -> None:
+    inherited = _info("t.NoMeta", parent="ffi.Object", total_size=None)
+    infos = {"ffi.Object": ROOT, "t.Base": BASE, "t.NoMeta": inherited}
+    verdicts = classify(infos, forced_opaque={"t.Base", "t.NoMeta"})
+    assert verdicts["t.Base"].reason == "by-directive"
+    assert verdicts["t.Base"].own_bytes == ByteRange(HEADER, 40)  # the 
evidence is still there
+    assert verdicts["t.NoMeta"].reason == "layout-unknown"  # a layout reason 
wins over the veto
+
+
+def test_field_renderable_predicate() -> None:
+    seen: list[str] = []
+
+    def renderable(field: NamedTypeSchema) -> bool:
+        seen.append(field.name)
+        return field.name != "v_i32"
+
+    hidden = _info("t.Hidden", parent="ffi.Object", total_size=48, 
fields=(_field("v_i64", 24, 8),))
+    infos = {"ffi.Object": ROOT, "t.Base": BASE, "t.Hidden": hidden}
+    verdicts = classify(infos, field_renderable=renderable)
+    assert verdicts["t.Base"].reason == "unrenderable-field"
+    assert "'v_i32'" in verdicts["t.Base"].detail
+    # A type whose bytes already fail is not asked about renderability.
+    assert verdicts["t.Hidden"].reason == "uncovered-bytes"
+    assert seen == ["v_i64", "v_i32"]
+
+
+def test_children_of_any_opaque_parent_are_parent_opaque() -> None:
+    child = _info("t.Child", parent="t.Base", total_size=48, 
fields=(_field("x", 40, 8),))
+    infos = {"ffi.Object": ROOT, "t.Base": BASE, "t.Child": child}
+    verdicts = classify(infos, forced_opaque={"t.Base"})
+    assert verdicts["t.Child"].reason == "parent-opaque"
+    assert "by-directive" in verdicts["t.Child"].detail
+
+
+def test_order_does_not_matter_but_ancestors_must_be_present() -> None:
+    child = _info("t.Child", parent="t.Base", total_size=48, 
fields=(_field("x", 40, 8),))
+    verdicts = classify({"t.Child": child, "t.Base": BASE, "ffi.Object": ROOT})
+    assert verdicts["t.Child"].is_complete
+    with pytest.raises(KeyError, match=r"t\.Base"):
+        classify({"t.Child": child})
+
+
+def test_coverage_report_shape(tmp_path: Path) -> None:
+    hidden = _info("t.Hidden", parent="ffi.Object", total_size=48, 
fields=(_field("v_i64", 24, 8),))
+    verdicts = classify({"t.Hidden": hidden, "t.Base": BASE, "ffi.Object": 
ROOT})
+    report = coverage_report(verdicts)
+    assert list(report) == ["ffi.Object", "t.Base", "t.Hidden"]  # sorted
+    entry = report["t.Base"]
+    assert entry == {
+        "verdict": "complete",
+        "reason": None,
+        "detail": entry["detail"],
+        "parent": "ffi.Object",
+        "ancestors": ["ffi.Object"],
+        "total_size": 40,
+        "is_final": False,
+        "own_bytes": [24, 40],
+        "fields": [
+            {"name": "v_i64", "offset": 24, "size": 8, "alignment": 8},
+            {"name": "v_i32", "offset": 32, "size": 4, "alignment": 4},
+        ],
+        "uncovered": [],
+        "padding": [[36, 40]],
+    }
+    assert report["t.Hidden"]["reason"] == "uncovered-bytes"
+    assert report["t.Hidden"]["uncovered"] == [[32, 48]]
+
+    out = tmp_path / "coverage.json"
+    write_coverage_report(out, verdicts)
+    assert json.loads(out.read_text(encoding="utf-8")) == report
+
+
+# ---------------------------------------------------------------------------
+# The real registry: the `testing.*` fixtures exercise every path in CI.
+# ---------------------------------------------------------------------------
+
+
+def _registry_verdicts() -> dict[str, Verdict]:
+    infos = {
+        type_key: object_info_from_type_key(type_key)
+        for type_keys in collect_type_keys().values()
+        for type_key in type_keys
+    }
+    return classify(infos)
+
+
+def test_registry_complete_chain() -> None:
+    verdicts = _registry_verdicts()
+    assert verdicts["ffi.Object"].is_complete
+    base = verdicts["testing.TestCxxClassBase"]
+    derived = verdicts["testing.TestCxxClassDerived"]
+    dd = verdicts["testing.TestCxxClassDerivedDerived"]
+    assert base.is_complete and derived.is_complete and dd.is_complete
+    assert (base.own_bytes, derived.own_bytes, dd.own_bytes) == (
+        ByteRange(24, 40),
+        ByteRange(40, 56),
+        ByteRange(56, 80),
+    )
+    assert _ranges(base.padding) == [(36, 40)]
+    assert _ranges(derived.padding) == [(52, 56)]
+    assert _ranges(dd.padding) == [(73, 80)]
+    # `ffi.Function` never registered an `ObjectDef`: nothing to check against.
+    assert verdicts["ffi.Function"].reason == "layout-unknown"
+
+
+def test_registry_hidden_field() -> None:
+    """A member the registry never saw shows up as an exact byte range."""
+    verdict = _registry_verdicts()["testing.TestCxxClassHiddenField"]
+    assert verdict.reason == "uncovered-bytes"
+    assert verdict.total_size == 48
+    assert verdict.fields == [FieldBytes("v_i64", 24, 8, 8), 
FieldBytes("v_i32", 40, 4, 4)]
+    assert _ranges(verdict.uncovered) == [(32, 40)]
+    assert _ranges(verdict.padding) == [(44, 48)]
+    assert verdict.is_final is True
+
+
+def test_registry_polymorphic() -> None:
+    """A vptr shifts `sizeof` by a pointer, and no polymorphism flag is needed 
to see it."""
+    verdicts = _registry_verdicts()
+    verdict = verdicts["testing.TestCxxClassPolymorphic"]
+    assert verdict.reason == "uncovered-bytes"
+    assert verdict.total_size == 40  # vptr + header + int64
+    assert verdict.fields == [FieldBytes("v_i64", 24, 8, 8)]
+    assert _ranges(verdict.uncovered) == [(32, 40)]
+    # `ffi.Module` is the same shape in production code.
+    assert verdicts["ffi.Module"].reason == "uncovered-bytes"
+
+
+def test_registry_tolerates_pending_py_class_registration() -> None:
+    """A ``py_class`` whose registration never completed has no fields yet; 
the sweep must not crash."""
+    parent_info = core._type_cls_to_type_info(core.Object)
+    assert parent_info is not None
+    cls = type("PendingLayout", (core.Object,), {"__slots__": ()})
+    core._register_py_class(parent_info, "testing.stub_layout.PendingLayout", 
cls)
+    verdict = _registry_verdicts()["testing.stub_layout.PendingLayout"]
+    assert verdict.reason == "layout-unknown"
+    assert verdict.fields == []
+
+
+def test_cli_coverage_out_without_files(tmp_path: Path, monkeypatch: 
pytest.MonkeyPatch) -> None:
+    out = tmp_path / "coverage.json"
+    monkeypatch.setattr("sys.argv", ["tvm-ffi-stubgen", "--coverage-out", 
str(out)])
+    assert stub_cli.__main__() == 0
+    report = json.loads(out.read_text(encoding="utf-8"))
+    assert report["ffi.Object"]["verdict"] == "complete"
+    assert report["testing.TestCxxClassBase"]["verdict"] == "complete"
+    assert report["testing.TestCxxClassHiddenField"]["uncovered"] == [[32, 40]]
+    assert report["testing.TestCxxClassPolymorphic"]["reason"] == 
"uncovered-bytes"
+    assert report["ffi.Function"]["reason"] == "layout-unknown"
+
+
+def test_cli_still_requires_files_without_coverage(monkeypatch: 
pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr("sys.argv", ["tvm-ffi-stubgen"])
+    with pytest.raises(SystemExit):
+        stub_cli.__main__()

Reply via email to