Add referenced types to facades, making result types discoverable in the REPL

Facades declare the types their methods return or contain as ReferencedTypes (with an optional
whitelist of members for curation, e.g. for LanguageServerSymbol, and a flag for inclusion in the
facade description). Type documentation is generated from class docstrings, attribute annotations,
properties and method signatures/docstrings, and is available via s.info("<facade>.<Type>") or by
bare type name; method documentation points to the documentation of a referenced return type.
Annotations are rendered without module paths, such that the names in signatures are the names by
which types can be looked up. s.info accepts several items at once, reporting unknown items inline.

All APIs declare their result types; result classes declare their attributes at class level.
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-09-15 12:50:44 +02:00
1 parent 05603b2706
commit 6cb3d37e09
11 files changed
+299 -34

No files matched your search

+9
View File
@@ -29,6 +29,15 @@ Code runs as a function body (`return` defines the result); a single expression
- Results expose data to code (`.symbols`, `.occurrences`, `.lines`, ...) and render like the classic tool output.
- Progressive disclosure: a priori only facade names, descriptions and method names; `s.info("<facade>")` /
`s.info("<facade>.<method>")` give signature + docstring together, never a signature alone.
`info(*items)` documents several items at once; unknown items are reported inline.
- Result types: a type returned by a single method is documented under `:return:`. Types that are shared,
contained (`LanguageServerSymbol`) or navigated are declared per facade as `ReferencedType`s (constructor
arg `types=`), with `provide_info_with_facade` (full description in `s.info("<facade>")`, else listed by name)
and an optional `members` whitelist (curation for foreign/large classes; listed methods are shown even if
undocumented, convention-derived ones only if documented). Types are documented via `s.info("<facade>.<Type>")`
or bare `s.info("<Type>")`; method docs point to their referenced return type. Result classes declare
attribute annotations at class level (attributes set only in `__init__` are not discoverable).
Annotations are rendered without module paths, so signature names equal lookup names.
- APIs must not import `serena.tools` at module level except for tool classes in decorators; tools import
APIs locally in `_api()` (API modules refer to tool classes).
+8 -1
View File
@@ -18,7 +18,7 @@ from serena.tools import (
)
from serena.util.text_utils import ContentReplacer, MultiFileReplacement, ReplacementOccurrence, ReplacementRejectedError
from ..facade import SUCCESS_RESULT, FacadeApi, facade_method
from ..facade import SUCCESS_RESULT, FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -74,6 +74,13 @@ class EditApi(FacadeApi):
agent,
name="edit",
description="modifying content within existing files (independent of the language backend)",
types=[
ReferencedType(ReplacementPreview, provide_info_with_facade=True),
ReferencedType(
ReplacementOccurrence,
members=["occurrence_id", "relative_path", "start_line", "end_line", "matched_text", "replacement", "is_ambiguous"],
),
],
)
# file-level operations
+21 -2
View File
@@ -14,7 +14,7 @@ from serena.util.file_system import scan_directory
from serena.util.text_utils import MatchedConsecutiveLines
from solidlsp.ls_utils import TextUtils
from ..facade import FacadeApi, facade_method
from ..facade import FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -34,6 +34,8 @@ class FileContent(RepresentableViaRenderer):
super().__init__(renderer)
self.lines = lines
lines: list[str]
@property
def text(self) -> str:
return "\n".join(self.lines)
@@ -59,6 +61,9 @@ class DirectoryListing(RepresentableViaRenderer):
self.dirs = dirs
self.files = files
dirs: list[str]
files: list[str]
class DirectoryListingRenderer(Renderer[DirectoryListing]):
def render(self, obj: DirectoryListing) -> str:
@@ -79,6 +84,8 @@ class PatternMatches(RepresentableViaRenderer):
super().__init__(renderer)
self.matches = matches
matches: list[MatchedConsecutiveLines]
def __len__(self) -> int:
return len(self.matches)
@@ -162,7 +169,19 @@ class PatternMatchesRenderer(Renderer[PatternMatches]):
class FsApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(agent, name="fs", description="the project's files as units (as opposed to their content, see `edit`)")
super().__init__(
agent,
name="fs",
description="the project's files as units (as opposed to their content, see `edit`)",
types=[
ReferencedType(FileContent),
ReferencedType(DirectoryListing),
ReferencedType(PatternMatches, provide_info_with_facade=True),
ReferencedType(
MatchedConsecutiveLines, members=["source_file_path", "matched_lines", "start_line", "end_line", "to_display_string"]
),
],
)
@facade_method(corresponding_tool=ReadFileTool)
def read_file(self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = -1) -> FileContent:
+11 -1
View File
@@ -30,7 +30,7 @@ from serena.tools import (
)
from serena.util.text_utils import find_text_coordinates
from ..facade import FacadeApi, facade_method
from ..facade import FacadeApi, ReferencedType, facade_method
from ..representable import JsonObject, JsonObjectRenderer, Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -44,6 +44,8 @@ class JetBrainsSymbolCollection(RepresentableViaRenderer):
`children`, `body`, `quick_info`, `documentation` and (for references) `context`.
"""
symbols: list[SymbolDTO]
def __init__(self, symbols: list[SymbolDTO], renderer: "JetBrainsSymbolCollectionRenderer"):
"""
:param symbols: the symbols
@@ -123,6 +125,9 @@ class JetBrainsSymbolsOverview(RepresentableViaRenderer):
self.symbols = symbols
self.documentation = documentation
symbols: list[SymbolDTO]
documentation: str | None
class JetBrainsSymbolsOverviewRenderer(Renderer[JetBrainsSymbolsOverview]):
"""
@@ -180,6 +185,11 @@ class JetBrainsApi(FacadeApi):
agent,
name="jb",
description="operations on the codebase backed by the JetBrains IDE's code intelligence",
types=[
ReferencedType(JetBrainsSymbolCollection, provide_info_with_facade=True),
ReferencedType(JetBrainsSymbolsOverview),
ReferencedType(JsonObject),
],
)
@contextmanager
+32 -1
View File
@@ -33,7 +33,7 @@ from serena.tools import (
from serena.util.text_utils import TextOutputUtils, find_text_coordinates
from solidlsp.lsp_protocol_handler.lsp_types import SymbolKind
from ..facade import SUCCESS_RESULT, FacadeApi, facade_method
from ..facade import SUCCESS_RESULT, FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -47,6 +47,8 @@ class LspSymbolCollection(RepresentableViaRenderer):
`body`, `get_body_line_numbers()`, `iter_children()`.
"""
symbols: list[LanguageServerSymbol]
def __init__(self, symbols: list[LanguageServerSymbol], renderer: "LspSymbolCollectionRenderer"):
"""
:param symbols: the list of symbols
@@ -70,6 +72,8 @@ class LspSymbol(RepresentableViaRenderer):
A single symbol retrieved via the language server (see `LspSymbolCollection` for the symbol's interface).
"""
symbol: LanguageServerSymbol
def __init__(self, symbol: LanguageServerSymbol, renderer: "LspSymbolRenderer"):
"""
:param symbol: the symbol
@@ -207,6 +211,8 @@ class LspReferenceCollection(RepresentableViaRenderer):
(a `LanguageServerSymbol`) and the `line` of the reference.
"""
references: list[ReferenceInLanguageServerSymbol]
def __init__(self, references: list[ReferenceInLanguageServerSymbol], renderer: "LspReferenceCollectionRenderer"):
"""
:param references: the references
@@ -273,6 +279,8 @@ class LspDiagnostics(RepresentableViaRenderer):
Diagnostics grouped as `relative_path -> severity -> name_path -> diagnostics`; see `grouped.get_dict()`.
"""
grouped: GroupedDiagnostics
def __init__(self, grouped: GroupedDiagnostics, renderer: "LspDiagnosticsRenderer"):
"""
:param grouped: the grouped diagnostics
@@ -304,6 +312,29 @@ class LspApi(FacadeApi):
agent,
name="lsp",
description="symbol-level operations on the codebase backed by language servers",
types=[
ReferencedType(LspSymbolCollection, provide_info_with_facade=True),
ReferencedType(
LanguageServerSymbol,
members=[
"name",
"get_name_path",
"relative_path",
"symbol_kind_name",
"line",
"column",
"body",
"get_body_line_numbers",
"iter_children",
"iter_ancestors",
"get_parent",
],
),
ReferencedType(LspSymbol),
ReferencedType(LspReferenceCollection),
ReferencedType(ReferenceInLanguageServerSymbol),
ReferencedType(LspDiagnostics),
],
)
def _create_symbol_retriever(self) -> LanguageServerSymbolRetriever:
+2 -1
View File
@@ -18,7 +18,7 @@ from serena.tools import (
WriteMemoryTool,
)
from ..facade import FacadeApi, facade_method
from ..facade import FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -60,6 +60,7 @@ class MemoryApi(FacadeApi):
agent,
name="mem",
description="project memories, i.e. persistent notes for future tasks",
types=[ReferencedType(MemoryList)],
)
def _get_memory_manager(self) -> MemoryManager:
+2 -2
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING
from serena.tools import ExecuteShellCommandTool
from serena.util.shell import ShellCommandResult, execute_shell_command
from ..facade import FacadeApi, facade_method
from ..facade import FacadeApi, ReferencedType, facade_method
from ..representable import Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
@@ -53,7 +53,7 @@ class ShellCommandOutputRenderer(Renderer[ShellCommandOutput]):
class ShellApi(FacadeApi):
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(agent, name="shell", description="execution of shell commands")
super().__init__(agent, name="shell", description="execution of shell commands", types=[ReferencedType(ShellCommandOutput)])
@facade_method(can_edit=True, corresponding_tool=ExecuteShellCommandTool)
def execute_shell_command(
+163 -14
View File
@@ -6,14 +6,18 @@ The facade, i.e. the object through which REPL code accesses a group of related
import inspect
import logging
import re
import typing
from abc import ABC
from collections.abc import Callable
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, TypeVar
from serena.config.serena_config import ApiInclusionDefinition
from serena.project import Project
from .representable import RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor
@@ -26,6 +30,105 @@ SUCCESS_RESULT = "OK"
"""the result returned by operations which have no result other than their success"""
def format_annotation(annotation: Any) -> str:
"""
:param annotation: a type annotation (or a signature/annotation string)
:return: the annotation rendered without module paths (e.g. `list[LanguageServerSymbol]`), such that type names
match the names by which the types can be looked up
"""
text = annotation if isinstance(annotation, str) else inspect.formatannotation(annotation)
return re.sub(r"\b(?:[A-Za-z_]\w*\.)+([A-Za-z_]\w*)", r"\1", text)
def format_signature(callable_: Callable[..., Any]) -> str:
"""
:param callable_: the callable
:return: the signature rendered without module paths in annotations
"""
return format_annotation(str(inspect.signature(callable_)))
@dataclass
class ReferencedType:
"""
A type that is referenced by a facade's methods (returned by them or contained in their results), whose interface
the LLM can inspect via `info`.
"""
cls: type
"""the type"""
provide_info_with_facade: bool = False
"""whether the type's full description is included in the facade's description (rather than just its name)"""
members: Sequence[str] | None = None
"""
the LLM-facing members (attributes, properties, methods) to describe; if None, all members admitted by the naming
convention (no leading or trailing underscore) which are documented are described. Explicitly listed methods are
described even if undocumented, as the listing is the documentation decision.
"""
_CAPABILITIES: typing.ClassVar[dict[str, str]] = {"__len__": "len()", "__iter__": "iteration", "__getitem__": "indexing"}
@property
def name(self) -> str:
return self.cls.__name__
def _get_member_names(self) -> list[str]:
if self.members is not None:
return list(self.members)
names = set(typing.get_type_hints(self.cls))
names.update(n for n in dir(self.cls) if not n.startswith("_"))
# exclude the representation mechanism, which is not meant to be used from REPL code
names.difference_update(dir(RepresentableViaRenderer))
return sorted(n for n in names if not n.startswith("_") and not n.endswith("_"))
@staticmethod
def _first_doc_line(obj: Any) -> str:
doc = inspect.getdoc(obj) or ""
first_line = doc.splitlines()[0] if doc else ""
return first_line.removeprefix(":return:").strip()
def describe(self) -> str:
"""
:return: the type's documentation: its docstring, attributes/properties with their types and methods with their
signatures and documentation
"""
attributes: list[str] = []
methods: list[str] = []
type_hints = typing.get_type_hints(self.cls)
for member_name in self._get_member_names():
member = inspect.getattr_static(self.cls, member_name, None)
if isinstance(member, property):
fget = member.fget
annotation = inspect.signature(fget).return_annotation if fget is not None else inspect.Signature.empty
type_str = f": {format_annotation(annotation)}" if annotation is not inspect.Signature.empty else ""
doc = self._first_doc_line(member)
attributes.append(f" {member_name}{type_str}" + (f" # {doc}" if doc else ""))
elif inspect.isfunction(member):
doc = inspect.getdoc(member)
if doc is None and self.members is None:
continue
signature = format_signature(member).replace("(self, ", "(", 1).replace("(self)", "()", 1)
methods.append(f" {member_name}{signature}" + (f"\n {doc.replace(chr(10), chr(10) + ' ')}" if doc else ""))
elif member_name in type_hints:
attributes.append(f" {member_name}: {format_annotation(type_hints[member_name])}")
else:
attributes.append(f" {member_name}")
# assemble the description
parts = [f"type {self.name}"]
if self.cls.__doc__: # NOTE: the class' own docstring (inspect.getdoc would fall back to base class docstrings)
doc = inspect.cleandoc(self.cls.__doc__)
parts.append(f" {doc.replace(chr(10), chr(10) + ' ')}")
if attributes:
parts.append("attributes:\n" + "\n".join(attributes))
if methods:
parts.append("methods:\n" + "\n".join(methods))
capabilities = [text for dunder, text in self._CAPABILITIES.items() if dunder in dir(self.cls) and dunder not in dir(object)]
if capabilities:
parts.append("supports: " + ", ".join(capabilities))
return "\n".join(parts) + "\n"
@dataclass(kw_only=True, frozen=True)
class FacadeMethodInfo:
"""
@@ -101,15 +204,18 @@ class FacadeApi(ABC):
* Names with a leading underscore are private, as usual.
"""
def __init__(self, agent: "SerenaAgent", name: str, description: str) -> None:
def __init__(self, agent: "SerenaAgent", name: str, description: str, types: Sequence[ReferencedType] = ()) -> None:
"""
:param agent: the agent providing access to the project and its resources
:param name: the attribute name under which the facade is accessible from the REPL entrypoint
:param description: a one-line description of the functionality offered by the facade
:param types: the types referenced by the facade's methods (returned or contained in results) whose interface
the LLM shall be able to inspect
"""
self._agent = agent
self._name = name
self._description = description
self._types = list(types)
def get_name_(self) -> str:
return self._name
@@ -117,6 +223,9 @@ class FacadeApi(ABC):
def get_description_(self) -> str:
return self._description
def get_referenced_types_(self) -> list[ReferencedType]:
return self._types
def _get_project(self) -> Project:
return self._agent.get_active_project_or_raise()
@@ -173,13 +282,22 @@ class FacadeMethod:
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self._implementation(*args, **kwargs)
def describe(self) -> str:
def describe(self, include_return_type_pointer: bool = True) -> str:
"""
:param include_return_type_pointer: whether to append a pointer to the documentation of the return type,
if it is a type referenced by the facade
:return: the method's signature and documentation
"""
signature = inspect.signature(self._implementation)
signature = format_signature(self._implementation)
doc = inspect.getdoc(self._implementation) or "(no documentation)"
return f"{self.qualified_name}{signature}\n{doc}\n"
text = f"{self.qualified_name}{signature}\n{doc}\n"
if include_return_type_pointer:
return_annotation = format_annotation(inspect.signature(self._implementation).return_annotation)
referenced = [t for t in self.parent.get_types() if re.search(rf"\b{re.escape(t.name)}\b", return_annotation)]
if referenced:
pointers = ", ".join(f'`s.info("{self.facade_name}.{t.name}")`' for t in referenced)
text += f"Return type: see {pointers}\n"
return text
class ApiScope:
@@ -282,11 +400,12 @@ class Facade:
A named group of related operations which an LLM can invoke from REPL code.
"""
def __init__(self, name: str, description: str) -> None:
def __init__(self, name: str, description: str, types: Sequence[ReferencedType] = ()) -> None:
# NOTE: attributes are set via object.__setattr__ because __getattr__ is overridden
object.__setattr__(self, "_name", name)
object.__setattr__(self, "_description", description)
object.__setattr__(self, "_methods", {})
object.__setattr__(self, "_types", {t.name: t for t in types})
def _add_method(self, method: FacadeMethod) -> None:
assert method.parent is self
@@ -301,7 +420,7 @@ class Facade:
:param api_scope: API scope definition determining which methods are enabled
:return: the facade
"""
facade = Facade(api.get_name_(), api.get_description_())
facade = Facade(api.get_name_(), api.get_description_(), api.get_referenced_types_())
for name, member in inspect.getmembers(api, predicate=inspect.ismethod):
method_info = get_facade_method_info(member)
if method_info is None:
@@ -328,6 +447,19 @@ class Facade:
"""
return [m for m in self._methods.values() if m.enabled]
def get_types(self) -> list[ReferencedType]:
"""
:return: the types referenced by the facade's methods
"""
return list(self._types.values())
def get_type(self, type_name: str) -> ReferencedType | None:
"""
:param type_name: the name of the type
:return: the referenced type, or None if the facade does not reference a type of that name
"""
return self._types.get(type_name)
def get_method(self, method_name: str) -> FacadeMethod:
"""
:param method_name: the name of the method
@@ -357,19 +489,36 @@ class Facade:
def describe(self) -> str:
"""
:return: a description of the facade listing all of its enabled methods with their signatures and documentation
as well as its referenced types (in full if so declared, otherwise by name)
"""
parts = [f"Facade '{self._name}': {self._description}", ""]
for method in self._methods.values():
if method.enabled:
parts.append(method.describe())
described_types = [t for t in self._types.values() if t.provide_info_with_facade]
listed_types = [t for t in self._types.values() if not t.provide_info_with_facade]
for referenced_type in described_types:
parts.append(referenced_type.describe())
if listed_types:
parts.append(
"Further types: "
+ ", ".join(t.name for t in listed_types)
+ f' (request documentation via `s.info("{self._name}.<type name>")`)'
)
return "\n".join(parts)
def describe_method(self, method_name: str) -> str:
def describe_member(self, member_name: str) -> str:
"""
:param method_name: the name of one of the facade's enabled methods
:return: the method's signature and documentation
:param member_name: the name of one of the facade's enabled methods or referenced types
:return: the member's documentation
"""
method = self._get_enabled_method(method_name)
if method is None:
raise ValueError(self._no_such_method_message(method_name))
return method.describe()
method = self._get_enabled_method(member_name)
if method is not None:
return method.describe()
referenced_type = self._types.get(member_name)
if referenced_type is not None:
return referenced_type.describe()
raise ValueError(
f"Facade '{self._name}' has no method or type '{member_name}'. "
f"Available methods: {self.enabled_method_names}; types: {list(self._types)}"
)
+23 -10
View File
@@ -60,22 +60,35 @@ class SerenaReplEntrypoint:
for facade in self._facades.values()
)
def info(self, path: str = "") -> str:
def info(self, *items: str) -> str:
"""
Provides documentation on the available functionality.
:param path: the empty string for an overview of all facades, a facade name (e.g. "lsp") for the
documentation of all of the facade's methods, or a dotted method path (e.g. "lsp.find_symbol")
for the documentation of a single method
:param items: the items to document; if none are given, an overview of all facades is provided.
Each item is either a facade name (e.g. "lsp") for the documentation of all of the facade's methods and types,
a dotted path (e.g. "lsp.find_symbol" or "lsp.LspSymbolCollection") for the documentation of a single method
or type, or a bare type name (e.g. "LanguageServerSymbol"), which is looked up across all facades.
Unknown items are reported without affecting the documentation of the other items.
:return: the requested documentation
"""
if path == "":
if not items:
return self.overview()
facade_name, _, method_name = path.partition(".")
facade = self._get_facade(facade_name)
if method_name == "":
return facade.describe()
return facade.describe_method(method_name)
return "\n\n".join(self._describe_item(item) for item in items)
def _describe_item(self, item: str) -> str:
facade_name, _, member_name = item.partition(".")
try:
if member_name:
return self._get_facade(facade_name).describe_member(member_name)
if facade_name in self._facades:
return self._facades[facade_name].describe()
# not a facade: look up the item as a type across all facades
matches = [facade for facade in self._facades.values() if facade.get_type(item) is not None]
if not matches:
raise ValueError(f"Unknown item '{item}': neither a facade nor a type. Available facades: {list(self._facades)}")
return matches[0].describe_member(item)
except ValueError as e:
return str(e)
class SerenaRepl:
+3
View File
@@ -92,6 +92,9 @@ class JsonObject(RepresentableViaRenderer):
super().__init__(renderer)
self.data = data
data: Any
"""the JSON-serializable data (dict, list, etc.)"""
class JsonObjectRenderer(Renderer[JsonObject]):
def render(self, obj: JsonObject) -> str:
+25 -2
View File
@@ -52,6 +52,29 @@ class TestReplExecution:
method_info = repl.execute('s.info("lsp.find_symbol")')
assert "name_path_pattern" in method_info
def test_type_discovery(self, repl: SerenaRepl) -> None:
# signatures render type names without module paths, and point to the documentation of referenced return types
method_info = repl.execute('s.info("lsp.find_symbol")')
assert "-> LspSymbolCollection" in method_info and "lsp_api." not in method_info
assert 's.info("lsp.LspSymbolCollection")' in method_info
# the facade description includes types declared as important in full and lists the others by name
facade_info = repl.execute('s.info("lsp")')
assert "type LspSymbolCollection" in facade_info and "symbols: list[LanguageServerSymbol]" in facade_info
assert "type LanguageServerSymbol" not in facade_info and "LanguageServerSymbol" in facade_info
# types can be requested via the facade or by bare name, and their curated members are documented
type_info = repl.execute('s.info("lsp.LanguageServerSymbol")')
assert type_info == repl.execute('s.info("LanguageServerSymbol")')
assert "get_name_path() -> str" in type_info and "iter_children()" in type_info
assert "to_dict" not in type_info # not among the curated members
assert "represent" not in repl.execute('s.info("LspSymbolCollection")') # the representation mechanism is not exposed
def test_info_documents_several_items(self, repl: SerenaRepl) -> None:
info = repl.execute('s.info("lsp.find_symbol", "nope", "lsp.LspSymbolCollection")')
assert "lsp.find_symbol(" in info and "type LspSymbolCollection" in info
assert "Unknown item 'nope'" in info # an unknown item does not prevent the documentation of the others
class TestFacade:
"""Tests the indirection between facades and their implementations."""
@@ -131,7 +154,7 @@ class TestFacade:
facade = Facade.from_api(self.DummyApi(MagicMock()), ApiScope())
assert facade.add(1, 2) == 3
assert "dummy.add(a: int, b: int) -> int" in facade.describe()
assert "Adds two numbers." in facade.describe_method("add")
assert "Adds two numbers." in facade.describe_member("add")
def test_disabled_methods_are_inaccessible_and_undocumented(self) -> None:
facade = Facade.from_api(self.DummyApi(MagicMock()), self._scope(excluded_apis=["dummy.secret"]))
@@ -139,7 +162,7 @@ class TestFacade:
with pytest.raises(AttributeError):
facade.secret()
with pytest.raises(ValueError):
facade.describe_method("secret")
facade.describe_member("secret")
assert "secret" not in facade.describe()
assert "_internal" not in facade.describe()