Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
822 changes: 822 additions & 0 deletions docs/superpowers/plans/2026-07-10-typealias-diagnostics.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions docs/superpowers/specs/2026-07-10-typealias-diagnostics-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Compile-time diagnostics for missing `typealias` entries

## Problem

When semiwrap parses a header that uses locally visible aliases or using-declarations, it may emit generated C++ that refers to a short type name such as `CantResolve`. If that name is not visible in the generated binding scope, the user currently sees an opaque C++/pybind11 compile error. The error can be much worse when generated trampolines and base-class trampoline composition are involved.

Semiwrap cannot definitively know during YAML/header processing whether a short C++ name will resolve in every generated C++ context. The diagnostic should therefore remain a compile-time diagnostic in generated C++.

## Design

Generate fail-fast C++ alias probes for type names that are likely to need a YAML `typealias` entry. A probe is a `using` declaration placed before the generated binding or trampoline code uses the type:

```cpp
// semiwrap diagnostic: if this line fails because `CantResolve` is unknown,
// add a typealias entry for it to the semiwrap yaml file.
using semiwrap_typealias_probe_CantResolve = CantResolve;
```

If `CantResolve` is not visible, compilation fails at this probe instead of deep inside pybind11 or trampoline templates. The compiler may not show a custom `static_assert` message, but the failing generated alias name and nearby comment identify the likely fix.

## Scope placement

Probes must be emitted in the same generated scope where the unresolved-prone type will later be used.

- For normal binding code, emit probes in the generated `.cpp` near existing user/auto typealiases and before `py::class_`, method, constructor, or function binding expressions that use the type.
- For classes with trampolines, emit class-scope probes in the generated trampoline `.hpp` before constructors, methods, and inherited trampoline composition use the type.
- Keep the generated probe names unique and deterministic so multiple probes cannot collide.

## Candidate types

Start with type spellings that semiwrap already emits in generated signatures, such as function return types and parameter types. Derive the probe target from the parsed type's base spelling, not from the full decorated spelling, so `const CantResolve&` produces a probe for `CantResolve`.

Only add probes for names that are plausible unresolved aliases:

- include unqualified or partially qualified names that appear in generated signatures;
- skip C++ built-in/fundamental types;
- skip names that are already fully qualified with a leading `::`;
- skip obvious standard-library names and other names semiwrap intentionally qualifies itself.

This keeps noise low while targeting cases like the `using.h` testcase.

The probe list is best-effort. It does not need to prove that a name is unresolved; it only needs to move failures for suspicious names to an earlier, clearer location. If a candidate type is already resolved, the probe compiles away as a harmless alias.

## Rejected alternatives

- Generation-time error or warning: semiwrap cannot reliably know whether C++ name lookup will succeed in generated contexts.
- `static_assert`-based type existence checks: C++ name lookup for a missing non-dependent type fails before `static_assert` or SFINAE can provide a custom diagnostic.
- Placeholder declarations for missing names: declaring dummy types changes name lookup, can shadow real types, and may make invalid code fail in misleading ways.
- Unconditional `#warning`/`#pragma message`: explicit but noisy and less portable. This can be considered later as an opt-in debug mode if needed.

## Testing

Add or adjust tests around the existing `using.h` testcase to verify the generated C++ contains typealias probes for short alias-like type names. A compile-failure fixture can then remove the YAML `typealias` and assert the compiler output points at the semiwrap probe name/comment area rather than pybind11/trampoline internals, if the test harness supports expected build failures.
9 changes: 9 additions & 0 deletions src/semiwrap/autowrap/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,11 @@ class ClassContext:
#: Extra autodetected 'using' directives
auto_typealias: typing.List[str] = field(default_factory=list)

#: Best-effort C++ type spellings that should be probed before generated
#: code uses them. These produce clearer compile-time diagnostics when a
#: YAML typealias entry is missing.
typealias_probes: typing.List[str] = field(default_factory=list)

#: vcheck are various static asserts that check things about the
#: inline functions
vcheck_fns: typing.List[FunctionContext] = field(default_factory=list)
Expand Down Expand Up @@ -568,6 +573,10 @@ class HeaderContext:
type_caster_includes: typing.List[str] = field(default_factory=list)
user_typealias: typing.List[str] = field(default_factory=list)

#: Best-effort C++ type spellings that should be probed before generated
#: global binding code uses them.
typealias_probes: typing.List[str] = field(default_factory=list)

using_ns: typing.List[str] = field(default_factory=list)

# All namespaces that occur in the file
Expand Down
146 changes: 144 additions & 2 deletions src/semiwrap/autowrap/cxxparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
PQName,
PQNameSegment,
Reference,
TemplateDecl,
TemplateInst,
TemplateNonTypeParam,
TemplateTypeParam,
Type,
Typedef,
Expand Down Expand Up @@ -91,6 +93,7 @@
TemplateInstanceContext,
TrampolineData,
)
from .typealias_probe import add_typealias_probe, collect_typealias_probes

from ..name_transform import NameTransforms, resolve_name_transforms
from ..util import relpath_walk_up
Expand Down Expand Up @@ -297,6 +300,7 @@ class ClassStateData(typing.NamedTuple):
data: ClassData

typealias_names: typing.Set[str]
local_typealias_names: typing.Set[str]

# have to defer processing these
defer_protected_methods: typing.List[Method]
Expand Down Expand Up @@ -368,6 +372,74 @@ def __init__(
)
self.types = set()
self.user_types = set()
self._extract_typealias(
self.user_cfg.typealias, self.hctx.user_typealias, set()
)

def _add_typealias_probes(
self,
probes: typing.List[str],
dtype: typing.Optional[typing.Union[DecoratedType, FunctionType]],
suppressed_names: typing.Set[str],
dependent_suppressed_names: typing.Optional[typing.Set[str]] = None,
suppressed_template_aliases: typing.Optional[typing.Set[str]] = None,
) -> None:
dependent_suppressed_names = dependent_suppressed_names or set()
suppressed_template_aliases = suppressed_template_aliases or set()
for probe in collect_typealias_probes(dtype):
if probe in suppressed_names:
continue
if self._probe_references_suppressed_name(
probe, dependent_suppressed_names
):
continue
if self._probe_uses_suppressed_template_alias(
probe, suppressed_template_aliases
):
continue
add_typealias_probe(probes, probe)

def _probe_references_suppressed_name(
self, probe: str, suppressed_names: typing.Set[str]
) -> bool:
for name in suppressed_names:
pattern = rf"(?<![0-9A-Za-z_]){re.escape(name)}(?![0-9A-Za-z_])"
if re.search(pattern, probe):
return True
return False

def _probe_uses_suppressed_template_alias(
self, probe: str, suppressed_aliases: typing.Set[str]
) -> bool:
return self._probe_references_suppressed_name(probe, suppressed_aliases)

def _template_typealias_names(
self, typealiases: typing.List[str]
) -> typing.Set[str]:
names: typing.Set[str] = set()
for typealias in typealiases:
if typealias.startswith("template"):
m = re.search(r"\busing\s+([A-Za-z_]\w*)\b", typealias)
if m:
names.add(m.group(1))
return names

def _template_type_param_names(
self,
template: typing.Optional[
typing.Union[TemplateDecl, typing.List[TemplateDecl]]
],
) -> typing.Set[str]:
if template is None:
return set()
if isinstance(template, list):
template = template[-1]
return {
param.name
for param in template.params
if isinstance(param, (TemplateTypeParam, TemplateNonTypeParam))
and param.name
}

#
# Visitor interface
Expand Down Expand Up @@ -476,6 +548,7 @@ def on_using_alias(self, state: AWState, using: UsingAlias) -> None:
ctx.auto_typealias.append(
f"using {using.alias} [[maybe_unused]] = typename {ctx.full_cpp_name}::{using.alias}"
)
state.user_data.local_typealias_names.add(using.alias)

def on_using_declaration(self, state: AWState, using: UsingDecl) -> None:
self._add_type_caster_pqname(using.typename)
Expand Down Expand Up @@ -602,6 +675,8 @@ def on_enum(self, state: AWState, enum: EnumDecl) -> None:
inline_code=enum_data.inline_code,
)
)
if ename and isinstance(user_data, ClassStateData):
user_data.local_typealias_names.add(ename)

#
# Class/union/struct
Expand Down Expand Up @@ -787,15 +862,22 @@ def on_class_start(self, state: AWClassBlockState) -> typing.Optional[bool]:
# Add to parent class or global class list
if parent_ctx:
parent_ctx.child_classes.append(ctx)
if isinstance(state.parent.user_data, ClassStateData):
state.parent.user_data.local_typealias_names.add(cls_name)
else:
self.hctx.classes.append(ctx)

local_typealias_names = {cls_name}
for param in class_data.template_params or []:
local_typealias_names.add(param.split()[-1])

# Store for other events to use
state.user_data = ClassStateData(
ctx=ctx,
cls_key=cls_key,
data=class_data,
typealias_names=typealias_names,
local_typealias_names=local_typealias_names,
# Method data
defer_protected_methods=[],
defer_private_nonvirtual_methods=[],
Expand Down Expand Up @@ -1130,6 +1212,45 @@ def _on_class_method(
overload_tracker,
)

needs_trampoline_signature_probe = (
is_virtual
and not state.class_decl.final
and not cdata.data.force_no_trampoline
)
if (
is_constructor
or state.access != "public"
or needs_trampoline_signature_probe
or fctx.is_overloaded
or fctx.genlambda
):
suppressed_names = set(cdata.local_typealias_names)
method_template_names = self._template_type_param_names(method.template)
suppressed_names.update(method_template_names)
class_template_names = {
param.split()[-1] for param in cdata.data.template_params or []
}
suppressed_template_aliases = set(cdata.local_typealias_names)
suppressed_template_aliases.difference_update(class_template_names)
suppressed_template_aliases.update(
self._template_typealias_names(cdata.data.typealias)
)
self._add_typealias_probes(
cctx.typealias_probes,
method.return_type,
suppressed_names,
method_template_names,
suppressed_template_aliases,
)
for param in method.parameters:
self._add_typealias_probes(
cctx.typealias_probes,
param.type,
suppressed_names,
method_template_names,
suppressed_template_aliases,
)

# Update class-specific method attributes
fctx.is_constructor = is_constructor
if is_constructor and method_data.rename and not method_data.cpp_code:
Expand Down Expand Up @@ -1992,6 +2113,10 @@ def _extract_typealias(
for typealias in in_ta:
if typealias.startswith("template"):
out_ta.append(typealias)
using_pos = typealias.find(" using ")
if using_pos != -1:
ta_name = typealias[using_pos + 7 :].split("=", 1)[0].strip()
ta_names.add(ta_name.split("<", 1)[0].strip())
else:
teq = typealias.find("=")
if teq != -1:
Expand Down Expand Up @@ -2192,8 +2317,25 @@ def parse_header(
if isinstance(param, str):
visitor._add_user_type_caster(param)

# User typealias additions
visitor._extract_typealias(user_cfg.typealias, hctx.user_typealias, set())
# Global function typealias probes. Do this after parsing so overload
# trackers have seen every overload.
for fctx in hctx.functions:
if fctx.is_overloaded or fctx.genlambda:
fn = fctx._fn
suppressed_names = visitor._template_type_param_names(fn.template)
visitor._add_typealias_probes(
hctx.typealias_probes,
fn.return_type,
suppressed_names,
suppressed_names,
)
for param in fn.parameters:
visitor._add_typealias_probes(
hctx.typealias_probes,
param.type,
suppressed_names,
suppressed_names,
)

# Type caster
visitor._set_type_caster_includes()
Expand Down
4 changes: 4 additions & 0 deletions src/semiwrap/autowrap/render_cls_trampoline_hpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
TrampolineData,
)
from .mangle import trampoline_signature
from .typealias_probe import render_typealias_probes

from . import render_pybind11 as rpybind11

Expand Down Expand Up @@ -222,6 +223,9 @@ def _render_cls_trampoline(
for typealias in cls.auto_typealias:
r.writeln(f"{typealias};")

if cls.typealias_probes:
render_typealias_probes(r, cls.typealias_probes)

if cls.constants:
r.writeln()
for name, constant in cls.constants:
Expand Down
23 changes: 23 additions & 0 deletions src/semiwrap/autowrap/render_pybind11.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import inspect
import pathlib
import typing as T

from .buffer import RenderBuffer
Expand All @@ -10,6 +11,7 @@
GeneratedLambda,
PropContext,
)
from .typealias_probe import render_typealias_probes


def mkdoc(pre: str, doc: Documentation, post: str) -> str:
Expand Down Expand Up @@ -305,6 +307,27 @@ def cls_user_using(r: RenderBuffer, cls: ClassContext):
r.writeln(f"{typealias};")


def _collect_class_typealias_probes(cls: ClassContext, probes: T.Set[str]) -> None:
probes.update(cls.typealias_probes)
for ccls in cls.child_classes:
if not ccls.template:
_collect_class_typealias_probes(ccls, probes)


def cls_typealias_probes(
r: RenderBuffer,
classes: T.Iterable[ClassContext],
*,
yaml_path: T.Optional[T.Union[str, pathlib.Path]] = None,
) -> None:
probes: T.Set[str] = set()
for cls in classes:
if not cls.template:
_collect_class_typealias_probes(cls, probes)
if probes:
render_typealias_probes(r, sorted(probes), yaml_path=yaml_path)


def cls_auto_using(r: RenderBuffer, cls: ClassContext):
for ccls in cls.child_classes:
if not ccls.template:
Expand Down
7 changes: 7 additions & 0 deletions src/semiwrap/autowrap/render_wrapped.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from . import render_pybind11 as rpybind11
from .render_cls_prologue import render_class_prologue
from .typealias_probe import render_typealias_probes


def render_wrapped_cpp(hctx: HeaderContext) -> str:
Expand Down Expand Up @@ -52,6 +53,10 @@ def render_wrapped_cpp(hctx: HeaderContext) -> str:
for ns in hctx.namespaces:
r.writeln(f"using namespace {ns};")

if hctx.typealias_probes:
r.writeln()
render_typealias_probes(r, hctx.typealias_probes, yaml_path=hctx.orig_yaml)

r.writeln(f"\nstruct semiwrap_{hctx.hname}_initializer {{\n")

with r.indent():
Expand All @@ -60,6 +65,8 @@ def render_wrapped_cpp(hctx: HeaderContext) -> str:
rpybind11.cls_user_using(r, cls)
rpybind11.cls_consts(r, cls)

rpybind11.cls_typealias_probes(r, hctx.classes, yaml_path=hctx.orig_yaml)

if hctx.subpackages:
r.writeln()
for vname in hctx.subpackages.values():
Expand Down
Loading
Loading